From 9a071a57f2c3aa4ec5ad54c536a59386922292d4 Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Mon, 21 May 2018 13:58:47 +0200 Subject: [PATCH 01/37] chore(version): bump version number --- api/portainer.go | 2 +- api/swagger.yaml | 4 ++-- distribution/portainer.spec | 2 +- package.json | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/api/portainer.go b/api/portainer.go index ac9fd0c92..1f2caeb9b 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -443,7 +443,7 @@ type ( const ( // APIVersion is the version number of the Portainer API. - APIVersion = "1.17.1" + APIVersion = "1.17.1-dev" // DBVersion is the version number of the Portainer database. DBVersion = 11 // DefaultTemplatesURL represents the default URL for the templates definitions. diff --git a/api/swagger.yaml b/api/swagger.yaml index 0f81812f1..edacf59d7 100644 --- a/api/swagger.yaml +++ b/api/swagger.yaml @@ -56,7 +56,7 @@ info: **NOTE**: You can find more information on how to query the Docker API in the [Docker official documentation](https://docs.docker.com/engine/api/v1.30/) as well as in [this Portainer example](https://gist.github.com/deviantony/77026d402366b4b43fa5918d41bc42f8). - version: "1.17.1" + version: "1.17.1-dev" title: "Portainer API" contact: email: "info@portainer.io" @@ -2176,7 +2176,7 @@ definitions: description: "Is analytics enabled" Version: type: "string" - example: "1.17.1" + example: "1.17.1-dev" description: "Portainer API version" PublicSettingsInspectResponse: type: "object" diff --git a/distribution/portainer.spec b/distribution/portainer.spec index 2795a7ae5..c6b13baa4 100644 --- a/distribution/portainer.spec +++ b/distribution/portainer.spec @@ -1,5 +1,5 @@ Name: portainer -Version: 1.17.1 +Version: 1.17.1-dev Release: 0 License: Zlib Summary: A lightweight docker management UI diff --git a/package.json b/package.json index f152c9882..067d94e40 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "author": "Portainer.io", "name": "portainer", "homepage": "http://portainer.io", - "version": "1.17.1", + "version": "1.17.1-dev", "repository": { "type": "git", "url": "git@github.com:portainer/portainer.git" From 6c520907ad8f9d4e4c6f2783451490046472ee3f Mon Sep 17 00:00:00 2001 From: Andrea Kao Date: Wed, 23 May 2018 05:47:43 -0700 Subject: [PATCH 02/37] chore(license): update license info so that GitHub recognizes it (#1924) GitHub uses a library called Licensee to identify a project's license type. It shows this information in the status bar and via the API if it can unambiguously identify the license. This commit modifies a few of Portainer's docs so that Licensee is able to recognize the repository's license type. It updates LICENSE so that it contains only the text of the zlib license. It also moves the info concerning 3rd-party software to a new "Licensing" section in the README. Collectively, these changes allow Licensee to successfully identify the license type of Portainer as zlib. Signed-off-by: Andrea Kao --- LICENSE | 46 ++-------------------------------------------- README.md | 10 ++++++++++ 2 files changed, 12 insertions(+), 44 deletions(-) diff --git a/LICENSE b/LICENSE index 03bda7a2e..231ab8be4 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Portainer: Copyright (c) 2016 Portainer.io +Copyright (c) 2018 Portainer.io This software is provided 'as-is', without any express or implied warranty. In no event will the authors be held liable for any damages @@ -14,46 +14,4 @@ freely, subject to the following restrictions: appreciated but is not required. 2. Altered source versions must be plainly marked as such, and must not be misrepresented as being the original software. -3. This notice may not be removed or altered from any source distribution. - -Portainer contains code which was originally under this license: - -UI For Docker: Copyright (c) 2013-2016 Michael Crosby (crosbymichael.com), Kevan Ahlquist (kevanahlquist.com), Anthony Lapenna (portainer.io) - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - -rdash-angular: Copyright (c) [2014] [Elliot Hesp] - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. +3. This notice may not be removed or altered from any source distribution. \ No newline at end of file diff --git a/README.md b/README.md index c84a7cd1e..2bc15665c 100644 --- a/README.md +++ b/README.md @@ -61,3 +61,13 @@ Unlike the public demo, the playground sessions are deleted after 4 hours. Apart Partial support for the following Docker versions (some features may not be available): * Docker 1.9 + +## Licensing + +Portainer is licensed under the zlib license. See [LICENSE](./LICENSE) for reference. + +Portainer also contains the following code, which is licensed under the [MIT license](https://opensource.org/licenses/MIT): + +UI For Docker: Copyright (c) 2013-2016 Michael Crosby (crosbymichael.com), Kevan Ahlquist (kevanahlquist.com), Anthony Lapenna (portainer.io) + +rdash-angular: Copyright (c) [2014] [Elliot Hesp] From 415c6ce5e116e24a989f8d42ac20b702bae9d8dd Mon Sep 17 00:00:00 2001 From: Sawood Alam Date: Fri, 25 May 2018 12:00:47 -0400 Subject: [PATCH 03/37] docs(README): drop support for Standalone Docker Swarm (#1934) * Dropped support for standalone Docker Swarm documented * A more verbose explaination of standalone Docker Swarm Support --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 2bc15665c..1ee305f3f 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ Unlike the public demo, the playground sessions are deleted after 4 hours. Apart **_Portainer_** has full support for the following Docker versions: * Docker 1.10 to the latest version -* Docker Swarm >= 1.2.3 +* Standalone Docker Swarm >= 1.2.3 _(**NOTE:** Use of Standalone Docker Swarm is being discouraged since the introduction of built-in Swarm Mode in Docker. While older versions of Portainer had support for Standalone Docker Swarm, Portainer 1.17.0 and newer **do not** support it. However, the built-in Swarm Mode of Docker is fully supported.)_ Partial support for the following Docker versions (some features may not be available): From 9ad9cc5e2db4968817eedbfc5674e1227a3f7a45 Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Mon, 28 May 2018 16:40:33 +0200 Subject: [PATCH 04/37] feat(azure): add experimental Azure endpoint support (#1936) --- api/errors.go | 5 + api/http/client/client.go | 53 ++++++ api/http/handler/azure.go | 102 +++++++++++ api/http/handler/endpoint.go | 75 +++++++- api/http/handler/handler.go | 3 + api/http/proxy/azure_transport.go | 81 +++++++++ .../{transport.go => docker_transport.go} | 0 api/http/proxy/factory.go | 16 +- api/http/proxy/manager.go | 46 ++--- api/http/proxy/reverse_proxy.go | 2 +- api/http/server.go | 6 + api/portainer.go | 31 ++-- app/__module.js | 1 + app/azure/_module.js | 48 ++++++ .../azure-sidebar-content.js | 3 + .../azureSidebarContent.html | 6 + .../containerGroupsDatatable.html | 104 ++++++++++++ .../containerGroupsDatatable.js | 14 ++ app/azure/models/container_group.js | 66 ++++++++ app/azure/models/location.js | 6 + app/azure/models/provider.js | 7 + app/azure/models/resource_group.js | 6 + app/azure/models/subscription.js | 4 + app/azure/rest/azure.js | 17 ++ app/azure/rest/container_group.js | 41 +++++ app/azure/rest/location.js | 12 ++ app/azure/rest/provider.js | 12 ++ app/azure/rest/resource_group.js | 12 ++ app/azure/rest/subscription.js | 12 ++ app/azure/services/azureService.js | 66 ++++++++ app/azure/services/containerGroupService.js | 33 ++++ app/azure/services/locationService.js | 24 +++ app/azure/services/providerService.js | 22 +++ app/azure/services/resourceGroupService.js | 24 +++ app/azure/services/subscriptionService.js | 24 +++ .../containerInstancesController.js | 41 +++++ .../containerinstances.html | 19 +++ .../createContainerInstanceController.js | 87 ++++++++++ .../create/createcontainerinstance.html | 160 ++++++++++++++++++ app/azure/views/dashboard/dashboard.html | 33 ++++ .../views/dashboard/dashboardController.js | 21 +++ .../containers/edit/containerController.js | 2 +- app/portainer/services/api/endpointService.js | 20 ++- app/portainer/services/fileUpload.js | 17 +- app/portainer/services/stateManager.js | 8 + app/portainer/views/auth/authController.js | 30 +++- .../create/createEndpointController.js | 39 ++++- .../endpoints/create/createendpoint.html | 129 +++++++++++--- .../views/init/endpoint/initEndpoint.html | 84 ++++++++- .../init/endpoint/initEndpointController.js | 41 ++++- app/portainer/views/sidebar/sidebar.html | 4 +- .../views/sidebar/sidebarController.js | 25 ++- 52 files changed, 1665 insertions(+), 79 deletions(-) create mode 100644 api/http/handler/azure.go create mode 100644 api/http/proxy/azure_transport.go rename api/http/proxy/{transport.go => docker_transport.go} (100%) create mode 100644 app/azure/_module.js create mode 100644 app/azure/components/azure-sidebar-content/azure-sidebar-content.js create mode 100644 app/azure/components/azure-sidebar-content/azureSidebarContent.html create mode 100644 app/azure/components/datatables/containergroups-datatable/containerGroupsDatatable.html create mode 100644 app/azure/components/datatables/containergroups-datatable/containerGroupsDatatable.js create mode 100644 app/azure/models/container_group.js create mode 100644 app/azure/models/location.js create mode 100644 app/azure/models/provider.js create mode 100644 app/azure/models/resource_group.js create mode 100644 app/azure/models/subscription.js create mode 100644 app/azure/rest/azure.js create mode 100644 app/azure/rest/container_group.js create mode 100644 app/azure/rest/location.js create mode 100644 app/azure/rest/provider.js create mode 100644 app/azure/rest/resource_group.js create mode 100644 app/azure/rest/subscription.js create mode 100644 app/azure/services/azureService.js create mode 100644 app/azure/services/containerGroupService.js create mode 100644 app/azure/services/locationService.js create mode 100644 app/azure/services/providerService.js create mode 100644 app/azure/services/resourceGroupService.js create mode 100644 app/azure/services/subscriptionService.js create mode 100644 app/azure/views/containerinstances/containerInstancesController.js create mode 100644 app/azure/views/containerinstances/containerinstances.html create mode 100644 app/azure/views/containerinstances/create/createContainerInstanceController.js create mode 100644 app/azure/views/containerinstances/create/createcontainerinstance.html create mode 100644 app/azure/views/dashboard/dashboard.html create mode 100644 app/azure/views/dashboard/dashboardController.js diff --git a/api/errors.go b/api/errors.go index 7cd39f445..709bafb92 100644 --- a/api/errors.go +++ b/api/errors.go @@ -44,6 +44,11 @@ const ( ErrEndpointAccessDenied = Error("Access denied to endpoint") ) +// Azure environment errors +const ( + ErrAzureInvalidCredentials = Error("Invalid Azure credentials") +) + // Endpoint group errors. const ( ErrEndpointGroupNotFound = Error("Endpoint group not found") diff --git a/api/http/client/client.go b/api/http/client/client.go index 438be12ad..338aed15d 100644 --- a/api/http/client/client.go +++ b/api/http/client/client.go @@ -2,15 +2,68 @@ package client import ( "crypto/tls" + "encoding/json" + "fmt" "net/http" + "net/url" "strings" "time" "github.com/portainer/portainer" ) +// HTTPClient represents a client to send HTTP requests. +type HTTPClient struct { + *http.Client +} + +// NewHTTPClient is used to build a new HTTPClient. +func NewHTTPClient() *HTTPClient { + return &HTTPClient{ + &http.Client{ + Timeout: time.Second * 5, + }, + } +} + +// AzureAuthenticationResponse represents an Azure API authentication response. +type AzureAuthenticationResponse struct { + AccessToken string `json:"access_token"` + ExpiresOn string `json:"expires_on"` +} + +// ExecuteAzureAuthenticationRequest is used to execute an authentication request +// against the Azure API. It re-uses the same http.Client. +func (client *HTTPClient) ExecuteAzureAuthenticationRequest(credentials *portainer.AzureCredentials) (*AzureAuthenticationResponse, error) { + loginURL := fmt.Sprintf("https://login.microsoftonline.com/%s/oauth2/token", credentials.TenantID) + params := url.Values{ + "grant_type": {"client_credentials"}, + "client_id": {credentials.ApplicationID}, + "client_secret": {credentials.AuthenticationKey}, + "resource": {"https://management.azure.com/"}, + } + + response, err := client.PostForm(loginURL, params) + if err != nil { + return nil, err + } + + if response.StatusCode != http.StatusOK { + return nil, portainer.ErrAzureInvalidCredentials + } + + var token AzureAuthenticationResponse + err = json.NewDecoder(response.Body).Decode(&token) + if err != nil { + return nil, err + } + + return &token, nil +} + // ExecutePingOperation will send a SystemPing operation HTTP request to a Docker environment // using the specified host and optional TLS configuration. +// It uses a new Http.Client for each operation. func ExecutePingOperation(host string, tlsConfig *tls.Config) (bool, error) { transport := &http.Transport{} diff --git a/api/http/handler/azure.go b/api/http/handler/azure.go new file mode 100644 index 000000000..a372244a1 --- /dev/null +++ b/api/http/handler/azure.go @@ -0,0 +1,102 @@ +package handler + +import ( + "strconv" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/proxy" + "github.com/portainer/portainer/http/security" + + "log" + "net/http" + "os" + + "github.com/gorilla/mux" +) + +// AzureHandler represents an HTTP API handler for proxying requests to the Azure API. +type AzureHandler struct { + *mux.Router + Logger *log.Logger + EndpointService portainer.EndpointService + EndpointGroupService portainer.EndpointGroupService + TeamMembershipService portainer.TeamMembershipService + ProxyManager *proxy.Manager +} + +// NewAzureHandler returns a new instance of AzureHandler. +func NewAzureHandler(bouncer *security.RequestBouncer) *AzureHandler { + h := &AzureHandler{ + Router: mux.NewRouter(), + Logger: log.New(os.Stderr, "", log.LstdFlags), + } + h.PathPrefix("/{id}/azure").Handler( + bouncer.AuthenticatedAccess(http.HandlerFunc(h.proxyRequestsToAzureAPI))) + return h +} + +func (handler *AzureHandler) checkEndpointAccess(endpoint *portainer.Endpoint, userID portainer.UserID) error { + memberships, err := handler.TeamMembershipService.TeamMembershipsByUserID(userID) + if err != nil { + return err + } + + group, err := handler.EndpointGroupService.EndpointGroup(endpoint.GroupID) + if err != nil { + return err + } + + if !security.AuthorizedEndpointAccess(endpoint, group, userID, memberships) { + return portainer.ErrEndpointAccessDenied + } + + return nil +} + +func (handler *AzureHandler) proxyRequestsToAzureAPI(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + id := vars["id"] + + parsedID, err := strconv.Atoi(id) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) + return + } + + endpointID := portainer.EndpointID(parsedID) + endpoint, err := handler.EndpointService.Endpoint(endpointID) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + tokenData, err := security.RetrieveTokenData(r) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + if tokenData.Role != portainer.AdministratorRole { + err = handler.checkEndpointAccess(endpoint, tokenData.ID) + if err != nil && err == portainer.ErrEndpointAccessDenied { + httperror.WriteErrorResponse(w, err, http.StatusForbidden, handler.Logger) + return + } else if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + } + + var proxy http.Handler + proxy = handler.ProxyManager.GetProxy(string(endpointID)) + if proxy == nil { + proxy, err = handler.ProxyManager.CreateAndRegisterProxy(endpoint) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + } + + http.StripPrefix("/"+id+"/azure", proxy).ServeHTTP(w, r) +} diff --git a/api/http/handler/endpoint.go b/api/http/handler/endpoint.go index 23c867303..972f766cf 100644 --- a/api/http/handler/endpoint.go +++ b/api/http/handler/endpoint.go @@ -80,6 +80,7 @@ type ( postEndpointPayload struct { name string url string + endpointType int publicURL string groupID int useTLS bool @@ -88,6 +89,9 @@ type ( caCert []byte cert []byte key []byte + azureApplicationID string + azureTenantID string + azureAuthenticationKey string } ) @@ -117,9 +121,46 @@ func (handler *EndpointHandler) handleGetEndpoints(w http.ResponseWriter, r *htt return } + for i := range filteredEndpoints { + filteredEndpoints[i].AzureCredentials = portainer.AzureCredentials{} + } + encodeJSON(w, filteredEndpoints, handler.Logger) } +func (handler *EndpointHandler) createAzureEndpoint(payload *postEndpointPayload) (*portainer.Endpoint, error) { + credentials := portainer.AzureCredentials{ + ApplicationID: payload.azureApplicationID, + TenantID: payload.azureTenantID, + AuthenticationKey: payload.azureAuthenticationKey, + } + + httpClient := client.NewHTTPClient() + _, err := httpClient.ExecuteAzureAuthenticationRequest(&credentials) + if err != nil { + return nil, err + } + + endpoint := &portainer.Endpoint{ + Name: payload.name, + URL: payload.url, + Type: portainer.AzureEnvironment, + GroupID: portainer.EndpointGroupID(payload.groupID), + PublicURL: payload.publicURL, + AuthorizedUsers: []portainer.UserID{}, + AuthorizedTeams: []portainer.TeamID{}, + Extensions: []portainer.EndpointExtension{}, + AzureCredentials: credentials, + } + + err = handler.EndpointService.CreateEndpoint(endpoint) + if err != nil { + return nil, err + } + + return endpoint, nil +} + func (handler *EndpointHandler) createTLSSecuredEndpoint(payload *postEndpointPayload) (*portainer.Endpoint, error) { tlsConfig, err := crypto.CreateTLSConfigurationFromBytes(payload.caCert, payload.cert, payload.key, payload.skipTLSClientVerification, payload.skipTLSServerVerification) if err != nil { @@ -236,6 +277,10 @@ func (handler *EndpointHandler) createUnsecuredEndpoint(payload *postEndpointPay } func (handler *EndpointHandler) createEndpoint(payload *postEndpointPayload) (*portainer.Endpoint, error) { + if portainer.EndpointType(payload.endpointType) == portainer.AzureEnvironment { + return handler.createAzureEndpoint(payload) + } + if payload.useTLS { return handler.createTLSSecuredEndpoint(payload) } @@ -245,11 +290,35 @@ func (handler *EndpointHandler) createEndpoint(payload *postEndpointPayload) (*p func convertPostEndpointRequestToPayload(r *http.Request) (*postEndpointPayload, error) { payload := &postEndpointPayload{} payload.name = r.FormValue("Name") + + endpointType := r.FormValue("EndpointType") + + if payload.name == "" || endpointType == "" { + return nil, ErrInvalidRequestFormat + } + + parsedType, err := strconv.Atoi(endpointType) + if err != nil { + return nil, err + } + payload.url = r.FormValue("URL") + payload.endpointType = parsedType + + if portainer.EndpointType(payload.endpointType) != portainer.AzureEnvironment && payload.url == "" { + return nil, ErrInvalidRequestFormat + } + payload.publicURL = r.FormValue("PublicURL") - if payload.name == "" || payload.url == "" { - return nil, ErrInvalidRequestFormat + if portainer.EndpointType(payload.endpointType) == portainer.AzureEnvironment { + payload.azureApplicationID = r.FormValue("AzureApplicationID") + payload.azureTenantID = r.FormValue("AzureTenantID") + payload.azureAuthenticationKey = r.FormValue("AzureAuthenticationKey") + + if payload.azureApplicationID == "" || payload.azureTenantID == "" || payload.azureAuthenticationKey == "" { + return nil, ErrInvalidRequestFormat + } } rawGroupID := r.FormValue("GroupID") @@ -336,6 +405,8 @@ func (handler *EndpointHandler) handleGetEndpoint(w http.ResponseWriter, r *http return } + endpoint.AzureCredentials = portainer.AzureCredentials{} + encodeJSON(w, endpoint, handler.Logger) } diff --git a/api/http/handler/handler.go b/api/http/handler/handler.go index 77c6ce478..70e8f4e8b 100644 --- a/api/http/handler/handler.go +++ b/api/http/handler/handler.go @@ -30,6 +30,7 @@ type Handler struct { SettingsHandler *SettingsHandler TemplatesHandler *TemplatesHandler DockerHandler *DockerHandler + AzureHandler *AzureHandler WebSocketHandler *WebSocketHandler UploadHandler *UploadHandler FileHandler *FileHandler @@ -64,6 +65,8 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { http.StripPrefix("/api/endpoints", h.StoridgeHandler).ServeHTTP(w, r) case strings.Contains(r.URL.Path, "/extensions"): http.StripPrefix("/api/endpoints", h.ExtensionHandler).ServeHTTP(w, r) + case strings.Contains(r.URL.Path, "/azure/"): + http.StripPrefix("/api/endpoints", h.AzureHandler).ServeHTTP(w, r) default: http.StripPrefix("/api", h.EndpointHandler).ServeHTTP(w, r) } diff --git a/api/http/proxy/azure_transport.go b/api/http/proxy/azure_transport.go new file mode 100644 index 000000000..dccf451b5 --- /dev/null +++ b/api/http/proxy/azure_transport.go @@ -0,0 +1,81 @@ +package proxy + +import ( + "net/http" + "strconv" + "sync" + "time" + + "github.com/portainer/portainer" + "github.com/portainer/portainer/http/client" +) + +type ( + azureAPIToken struct { + value string + expirationTime time.Time + } + + // AzureTransport represents a transport used when executing HTTP requests + // against the Azure API. + AzureTransport struct { + credentials *portainer.AzureCredentials + client *client.HTTPClient + token *azureAPIToken + mutex sync.Mutex + } +) + +// NewAzureTransport returns a pointer to an AzureTransport instance. +func NewAzureTransport(credentials *portainer.AzureCredentials) *AzureTransport { + return &AzureTransport{ + credentials: credentials, + client: client.NewHTTPClient(), + } +} + +func (transport *AzureTransport) authenticate() error { + token, err := transport.client.ExecuteAzureAuthenticationRequest(transport.credentials) + if err != nil { + return err + } + + expiresOn, err := strconv.ParseInt(token.ExpiresOn, 10, 64) + if err != nil { + return err + } + + transport.token = &azureAPIToken{ + value: token.AccessToken, + expirationTime: time.Unix(expiresOn, 0), + } + + return nil +} + +func (transport *AzureTransport) retrieveAuthenticationToken() error { + transport.mutex.Lock() + defer transport.mutex.Unlock() + + if transport.token == nil { + return transport.authenticate() + } + + timeLimit := time.Now().Add(-5 * time.Minute) + if timeLimit.After(transport.token.expirationTime) { + return transport.authenticate() + } + + return nil +} + +// RoundTrip is the implementation of the Transport interface. +func (transport *AzureTransport) RoundTrip(request *http.Request) (*http.Response, error) { + err := transport.retrieveAuthenticationToken() + if err != nil { + return nil, err + } + + request.Header.Set("Authorization", "Bearer "+transport.token.value) + return http.DefaultTransport.RoundTrip(request) +} diff --git a/api/http/proxy/transport.go b/api/http/proxy/docker_transport.go similarity index 100% rename from api/http/proxy/transport.go rename to api/http/proxy/docker_transport.go diff --git a/api/http/proxy/factory.go b/api/http/proxy/factory.go index 8f952f2dc..70ba4543d 100644 --- a/api/http/proxy/factory.go +++ b/api/http/proxy/factory.go @@ -10,6 +10,8 @@ import ( "github.com/portainer/portainer/crypto" ) +const azureAPIBaseURL = "https://management.azure.com" + // proxyFactory is a factory to create reverse proxies to Docker endpoints type proxyFactory struct { ResourceControlService portainer.ResourceControlService @@ -20,11 +22,23 @@ type proxyFactory struct { SignatureService portainer.DigitalSignatureService } -func (factory *proxyFactory) newExtensionHTTPPRoxy(u *url.URL) http.Handler { +func (factory *proxyFactory) newHTTPProxy(u *url.URL) http.Handler { u.Scheme = "http" return newSingleHostReverseProxyWithHostHeader(u) } +func newAzureProxy(credentials *portainer.AzureCredentials) (http.Handler, error) { + url, err := url.Parse(azureAPIBaseURL) + if err != nil { + return nil, err + } + + proxy := newSingleHostReverseProxyWithHostHeader(url) + proxy.Transport = NewAzureTransport(credentials) + + return proxy, nil +} + func (factory *proxyFactory) newDockerHTTPSProxy(u *url.URL, tlsConfig *portainer.TLSConfiguration, enableSignature bool) (http.Handler, error) { u.Scheme = "https" diff --git a/api/http/proxy/manager.go b/api/http/proxy/manager.go index 2a9018102..f2a691634 100644 --- a/api/http/proxy/manager.go +++ b/api/http/proxy/manager.go @@ -44,33 +44,39 @@ func NewManager(parameters *ManagerParams) *Manager { } } -// CreateAndRegisterProxy creates a new HTTP reverse proxy 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) CreateAndRegisterProxy(endpoint *portainer.Endpoint) (http.Handler, error) { - var proxy http.Handler +func (manager *Manager) createDockerProxy(endpointURL *url.URL, tlsConfig *portainer.TLSConfiguration) (http.Handler, error) { + if endpointURL.Scheme == "tcp" { + if tlsConfig.TLS || tlsConfig.TLSSkipVerify { + return manager.proxyFactory.newDockerHTTPSProxy(endpointURL, tlsConfig, false) + } + return manager.proxyFactory.newDockerHTTPProxy(endpointURL, false), nil + } + // Assume unix:// scheme + return manager.proxyFactory.newDockerSocketProxy(endpointURL.Path), nil +} +func (manager *Manager) createProxy(endpoint *portainer.Endpoint) (http.Handler, error) { endpointURL, err := url.Parse(endpoint.URL) if err != nil { return nil, err } - enableSignature := false - if endpoint.Type == portainer.AgentOnDockerEnvironment { - enableSignature = true + switch endpoint.Type { + case portainer.AgentOnDockerEnvironment: + return manager.proxyFactory.newDockerHTTPSProxy(endpointURL, &endpoint.TLSConfig, true) + case portainer.AzureEnvironment: + return newAzureProxy(&endpoint.AzureCredentials) + default: + return manager.createDockerProxy(endpointURL, &endpoint.TLSConfig) } +} - if endpointURL.Scheme == "tcp" { - if endpoint.TLSConfig.TLS { - proxy, err = manager.proxyFactory.newDockerHTTPSProxy(endpointURL, &endpoint.TLSConfig, enableSignature) - if err != nil { - return nil, err - } - } else { - proxy = manager.proxyFactory.newDockerHTTPProxy(endpointURL, enableSignature) - } - } else { - // Assume unix:// scheme - proxy = manager.proxyFactory.newDockerSocketProxy(endpointURL.Path) +// CreateAndRegisterProxy 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) CreateAndRegisterProxy(endpoint *portainer.Endpoint) (http.Handler, error) { + proxy, err := manager.createProxy(endpoint) + if err != nil { + return nil, err } manager.proxies.Set(string(endpoint.ID), proxy) @@ -99,7 +105,7 @@ func (manager *Manager) CreateAndRegisterExtensionProxy(key, extensionAPIURL str return nil, err } - proxy := manager.proxyFactory.newExtensionHTTPPRoxy(extensionURL) + proxy := manager.proxyFactory.newHTTPProxy(extensionURL) manager.extensionProxies.Set(key, proxy) return proxy, nil } diff --git a/api/http/proxy/reverse_proxy.go b/api/http/proxy/reverse_proxy.go index 4862de9a9..47e71b63e 100644 --- a/api/http/proxy/reverse_proxy.go +++ b/api/http/proxy/reverse_proxy.go @@ -7,7 +7,7 @@ import ( "strings" ) -// NewSingleHostReverseProxyWithHostHeader is based on NewSingleHostReverseProxy +// newSingleHostReverseProxyWithHostHeader is based on NewSingleHostReverseProxy // from golang.org/src/net/http/httputil/reverseproxy.go and merely sets the Host // HTTP header, which NewSingleHostReverseProxy deliberately preserves. func newSingleHostReverseProxyWithHostHeader(target *url.URL) *httputil.ReverseProxy { diff --git a/api/http/server.go b/api/http/server.go index 5e85f8efa..c32374e20 100644 --- a/api/http/server.go +++ b/api/http/server.go @@ -88,6 +88,11 @@ func (server *Server) Start() error { dockerHandler.EndpointGroupService = server.EndpointGroupService dockerHandler.TeamMembershipService = server.TeamMembershipService dockerHandler.ProxyManager = proxyManager + var azureHandler = handler.NewAzureHandler(requestBouncer) + azureHandler.EndpointService = server.EndpointService + azureHandler.EndpointGroupService = server.EndpointGroupService + azureHandler.TeamMembershipService = server.TeamMembershipService + azureHandler.ProxyManager = proxyManager var websocketHandler = handler.NewWebSocketHandler() websocketHandler.EndpointService = server.EndpointService websocketHandler.SignatureService = server.SignatureService @@ -140,6 +145,7 @@ func (server *Server) Start() error { StackHandler: stackHandler, TemplatesHandler: templatesHandler, DockerHandler: dockerHandler, + AzureHandler: azureHandler, WebSocketHandler: websocketHandler, FileHandler: fileHandler, UploadHandler: uploadHandler, diff --git a/api/portainer.go b/api/portainer.go index 1f2caeb9b..413b37d39 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -175,16 +175,17 @@ 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"` - AuthorizedUsers []UserID `json:"AuthorizedUsers"` - AuthorizedTeams []TeamID `json:"AuthorizedTeams"` - Extensions []EndpointExtension `json:"Extensions"` + 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"` + AuthorizedUsers []UserID `json:"AuthorizedUsers"` + AuthorizedTeams []TeamID `json:"AuthorizedTeams"` + Extensions []EndpointExtension `json:"Extensions"` + AzureCredentials AzureCredentials `json:"AzureCredentials,omitempty"` // Deprecated fields // Deprecated in DBVersion == 4 @@ -194,6 +195,14 @@ type ( TLSKeyPath string `json:"TLSKey,omitempty"` } + // AzureCredentials represents the credentials used to connect to an Azure + // environment. + AzureCredentials struct { + ApplicationID string `json:"ApplicationID"` + TenantID string `json:"TenantID"` + AuthenticationKey string `json:"AuthenticationKey"` + } + // EndpointGroupID represents an endpoint group identifier. EndpointGroupID int @@ -530,4 +539,6 @@ const ( DockerEnvironment // AgentOnDockerEnvironment represents an endpoint connected to a Portainer agent deployed on a Docker environment AgentOnDockerEnvironment + // AzureEnvironment represents an endpoint connected to an Azure environment + AzureEnvironment ) diff --git a/app/__module.js b/app/__module.js index f5ebe25a2..a1c438489 100644 --- a/app/__module.js +++ b/app/__module.js @@ -18,6 +18,7 @@ angular.module('portainer', [ 'portainer.templates', 'portainer.app', 'portainer.agent', + 'portainer.azure', 'portainer.docker', 'extension.storidge', 'rzModule']); diff --git a/app/azure/_module.js b/app/azure/_module.js new file mode 100644 index 000000000..0879e9a54 --- /dev/null +++ b/app/azure/_module.js @@ -0,0 +1,48 @@ +angular.module('portainer.azure', ['portainer.app']) +.config(['$stateRegistryProvider', function ($stateRegistryProvider) { + 'use strict'; + + var azure = { + name: 'azure', + parent: 'root', + abstract: true + }; + + var containerInstances = { + name: 'azure.containerinstances', + url: '/containerinstances', + views: { + 'content@': { + templateUrl: 'app/azure/views/containerinstances/containerinstances.html', + controller: 'AzureContainerInstancesController' + } + } + }; + + var containerInstanceCreation = { + name: 'azure.containerinstances.new', + url: '/new/', + views: { + 'content@': { + templateUrl: 'app/azure/views/containerinstances/create/createcontainerinstance.html', + controller: 'AzureCreateContainerInstanceController' + } + } + }; + + var dashboard = { + name: 'azure.dashboard', + url: '/dashboard', + views: { + 'content@': { + templateUrl: 'app/azure/views/dashboard/dashboard.html', + controller: 'AzureDashboardController' + } + } + }; + + $stateRegistryProvider.register(azure); + $stateRegistryProvider.register(containerInstances); + $stateRegistryProvider.register(containerInstanceCreation); + $stateRegistryProvider.register(dashboard); +}]); diff --git a/app/azure/components/azure-sidebar-content/azure-sidebar-content.js b/app/azure/components/azure-sidebar-content/azure-sidebar-content.js new file mode 100644 index 000000000..d1f9230f4 --- /dev/null +++ b/app/azure/components/azure-sidebar-content/azure-sidebar-content.js @@ -0,0 +1,3 @@ +angular.module('portainer.azure').component('azureSidebarContent', { + templateUrl: 'app/azure/components/azure-sidebar-content/azureSidebarContent.html' +}); diff --git a/app/azure/components/azure-sidebar-content/azureSidebarContent.html b/app/azure/components/azure-sidebar-content/azureSidebarContent.html new file mode 100644 index 000000000..01986e8e7 --- /dev/null +++ b/app/azure/components/azure-sidebar-content/azureSidebarContent.html @@ -0,0 +1,6 @@ + + diff --git a/app/azure/components/datatables/containergroups-datatable/containerGroupsDatatable.html b/app/azure/components/datatables/containergroups-datatable/containerGroupsDatatable.html new file mode 100644 index 000000000..c87f74735 --- /dev/null +++ b/app/azure/components/datatables/containergroups-datatable/containerGroupsDatatable.html @@ -0,0 +1,104 @@ +
+ + +
+
+ {{ $ctrl.title }} +
+
+ + Search + +
+
+
+ + +
+ +
+ + + + + + + + + + + + + + + + + + + + + +
+ + + + + + Name + + + + + + Location + + + + + Published Ports +
+ + + + + {{ item.Name | truncate:50 }} + {{ item.Location }} + + :{{ p.port }} + + - +
Loading...
No container available.
+
+ +
+
+
diff --git a/app/azure/components/datatables/containergroups-datatable/containerGroupsDatatable.js b/app/azure/components/datatables/containergroups-datatable/containerGroupsDatatable.js new file mode 100644 index 000000000..c83550ca5 --- /dev/null +++ b/app/azure/components/datatables/containergroups-datatable/containerGroupsDatatable.js @@ -0,0 +1,14 @@ +angular.module('portainer.azure').component('containergroupsDatatable', { + templateUrl: 'app/azure/components/datatables/containergroups-datatable/containerGroupsDatatable.html', + controller: 'GenericDatatableController', + bindings: { + title: '@', + titleIcon: '@', + dataset: '<', + tableKey: '@', + orderBy: '@', + reverseOrder: '<', + showTextFilter: '<', + removeAction: '<' + } +}); diff --git a/app/azure/models/container_group.js b/app/azure/models/container_group.js new file mode 100644 index 000000000..0ba06a690 --- /dev/null +++ b/app/azure/models/container_group.js @@ -0,0 +1,66 @@ +function ContainerGroupDefaultModel() { + this.Location = ''; + this.OSType = 'Linux'; + this.Name = ''; + this.Image = ''; + this.AllocatePublicIP = true; + this.Ports = [ + { + container: 80, + host: 80, + protocol: 'TCP' + } + ]; + this.CPU = 1; + this.Memory = 1; +} + +function ContainerGroupViewModel(data, subscriptionId, resourceGroupName) { + this.Id = data.id; + this.Name = data.name; + this.Location = data.location; + this.IPAddress = data.properties.ipAddress.ip; + this.Ports = data.properties.ipAddress.ports; +} + +function CreateContainerGroupRequest(model) { + this.location = model.Location; + + var containerPorts = []; + var addressPorts = []; + for (var i = 0; i < model.Ports.length; i++) { + var binding = model.Ports[i]; + + containerPorts.push({ + port: binding.container + }); + + addressPorts.push({ + port: binding.host, + protocol: binding.protocol + }); + } + + this.properties = { + osType: model.OSType, + containers: [ + { + name: model.Name, + properties: { + image: model.Image, + ports: containerPorts, + resources: { + requests: { + cpu: model.CPU, + memoryInGB: model.Memory + } + } + } + } + ], + ipAddress: { + type: model.AllocatePublicIP ? 'Public': 'Private', + ports: addressPorts + } + }; +} diff --git a/app/azure/models/location.js b/app/azure/models/location.js new file mode 100644 index 000000000..a010776ba --- /dev/null +++ b/app/azure/models/location.js @@ -0,0 +1,6 @@ +function LocationViewModel(data) { + this.Id = data.id; + this.SubscriptionId = data.subscriptionId; + this.DisplayName = data.displayName; + this.Name = data.name; +} diff --git a/app/azure/models/provider.js b/app/azure/models/provider.js new file mode 100644 index 000000000..48cca79e9 --- /dev/null +++ b/app/azure/models/provider.js @@ -0,0 +1,7 @@ +function ContainerInstanceProviderViewModel(data) { + this.Id = data.id; + this.Namespace = data.namespace; + + var containerGroupType = _.find(data.resourceTypes, { 'resourceType': 'containerGroups' }); + this.Locations = containerGroupType.locations; +} diff --git a/app/azure/models/resource_group.js b/app/azure/models/resource_group.js new file mode 100644 index 000000000..aa04f1809 --- /dev/null +++ b/app/azure/models/resource_group.js @@ -0,0 +1,6 @@ +function ResourceGroupViewModel(data, subscriptionId) { + this.Id = data.id; + this.SubscriptionId = subscriptionId; + this.Name = data.name; + this.Location = data.location; +} diff --git a/app/azure/models/subscription.js b/app/azure/models/subscription.js new file mode 100644 index 000000000..2baa0da4d --- /dev/null +++ b/app/azure/models/subscription.js @@ -0,0 +1,4 @@ +function SubscriptionViewModel(data) { + this.Id = data.subscriptionId; + this.Name = data.displayName; +} diff --git a/app/azure/rest/azure.js b/app/azure/rest/azure.js new file mode 100644 index 000000000..2621c7d50 --- /dev/null +++ b/app/azure/rest/azure.js @@ -0,0 +1,17 @@ +angular.module('portainer.azure') +.factory('Azure', ['$http', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', +function AzureFactory($http, API_ENDPOINT_ENDPOINTS, EndpointProvider) { + 'use strict'; + + var service = {}; + + service.delete = function(id, apiVersion) { + var url = API_ENDPOINT_ENDPOINTS + '/' + EndpointProvider.endpointID() + '/azure' + id + '?api-version=' + apiVersion; + return $http({ + method: 'DELETE', + url: url + }); + }; + + return service; +}]); diff --git a/app/azure/rest/container_group.js b/app/azure/rest/container_group.js new file mode 100644 index 000000000..76ed5c4dc --- /dev/null +++ b/app/azure/rest/container_group.js @@ -0,0 +1,41 @@ +angular.module('portainer.azure') +.factory('ContainerGroup', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', +function ContainerGroupFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) { + 'use strict'; + + var resource = {}; + + var base = $resource( + API_ENDPOINT_ENDPOINTS +'/:endpointId/azure/subscriptions/:subscriptionId/providers/Microsoft.ContainerInstance/containerGroups', + { + 'endpointId': EndpointProvider.endpointID, + 'api-version': '2018-04-01' + }, + { + query: { method: 'GET', params: { subscriptionId: '@subscriptionId' } } + } + ); + + var withResourceGroup = $resource( + API_ENDPOINT_ENDPOINTS +'/:endpointId/azure/subscriptions/:subscriptionId/resourceGroups/:resourceGroupName/providers/Microsoft.ContainerInstance/containerGroups/:containerGroupName', + { + 'endpointId': EndpointProvider.endpointID, + 'api-version': '2018-04-01' + }, + { + create: { + method: 'PUT', + params: { + subscriptionId: '@subscriptionId', + resourceGroupName: '@resourceGroupName', + containerGroupName: '@containerGroupName' + } + } + } + ); + + resource.query = base.query; + resource.create = withResourceGroup.create; + + return resource; +}]); diff --git a/app/azure/rest/location.js b/app/azure/rest/location.js new file mode 100644 index 000000000..9516761c6 --- /dev/null +++ b/app/azure/rest/location.js @@ -0,0 +1,12 @@ +angular.module('portainer.azure') +.factory('Location', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', +function LocationFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) { + 'use strict'; + return $resource(API_ENDPOINT_ENDPOINTS +'/:endpointId/azure/subscriptions/:subscriptionId/locations', { + 'endpointId': EndpointProvider.endpointID, + 'api-version': '2016-06-01' + }, + { + query: { method: 'GET', params: { subscriptionId: '@subscriptionId' } } + }); +}]); diff --git a/app/azure/rest/provider.js b/app/azure/rest/provider.js new file mode 100644 index 000000000..e1a848182 --- /dev/null +++ b/app/azure/rest/provider.js @@ -0,0 +1,12 @@ +angular.module('portainer.azure') +.factory('Provider', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', +function ProviderFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) { + 'use strict'; + return $resource(API_ENDPOINT_ENDPOINTS +'/:endpointId/azure/subscriptions/:subscriptionId/providers/:providerNamespace', { + 'endpointId': EndpointProvider.endpointID, + 'api-version': '2018-02-01' + }, + { + get: { method: 'GET', params: { subscriptionId: '@subscriptionId', providerNamespace: '@providerNamespace' } } + }); +}]); diff --git a/app/azure/rest/resource_group.js b/app/azure/rest/resource_group.js new file mode 100644 index 000000000..2147682e3 --- /dev/null +++ b/app/azure/rest/resource_group.js @@ -0,0 +1,12 @@ +angular.module('portainer.azure') +.factory('ResourceGroup', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', +function ResourceGroupFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) { + 'use strict'; + return $resource(API_ENDPOINT_ENDPOINTS +'/:endpointId/azure/subscriptions/:subscriptionId/resourcegroups', { + 'endpointId': EndpointProvider.endpointID, + 'api-version': '2018-02-01' + }, + { + query: { method: 'GET', params: { subscriptionId: '@subscriptionId' } } + }); +}]); diff --git a/app/azure/rest/subscription.js b/app/azure/rest/subscription.js new file mode 100644 index 000000000..5b30974c6 --- /dev/null +++ b/app/azure/rest/subscription.js @@ -0,0 +1,12 @@ +angular.module('portainer.azure') +.factory('Subscription', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', +function SubscriptionFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) { + 'use strict'; + return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/azure/subscriptions', { + 'endpointId': EndpointProvider.endpointID, + 'api-version': '2016-06-01' + }, + { + query: { method: 'GET' } + }); +}]); diff --git a/app/azure/services/azureService.js b/app/azure/services/azureService.js new file mode 100644 index 000000000..8d7def765 --- /dev/null +++ b/app/azure/services/azureService.js @@ -0,0 +1,66 @@ +angular.module('portainer.azure') +.factory('AzureService', ['$q', 'Azure', 'SubscriptionService', 'ResourceGroupService', 'ContainerGroupService', 'ProviderService', +function AzureServiceFactory($q, Azure, SubscriptionService, ResourceGroupService, ContainerGroupService, ProviderService) { + 'use strict'; + var service = {}; + + service.deleteContainerGroup = function(id) { + return Azure.delete(id, '2018-04-01'); + }; + + service.createContainerGroup = function(model, subscriptionId, resourceGroupName) { + return ContainerGroupService.create(model, subscriptionId, resourceGroupName); + }; + + service.subscriptions = function() { + return SubscriptionService.subscriptions(); + }; + + service.containerInstanceProvider = function(subscriptions) { + return retrieveResourcesForEachSubscription(subscriptions, ProviderService.containerInstanceProvider); + }; + + service.resourceGroups = function(subscriptions) { + return retrieveResourcesForEachSubscription(subscriptions, ResourceGroupService.resourceGroups); + }; + + service.containerGroups = function(subscriptions) { + return retrieveResourcesForEachSubscription(subscriptions, ContainerGroupService.containerGroups); + }; + + service.aggregate = function(resourcesBySubcription) { + var aggregatedResources = []; + Object.keys(resourcesBySubcription).forEach(function(key, index) { + aggregatedResources = aggregatedResources.concat(resourcesBySubcription[key]); + }); + return aggregatedResources; + }; + + function retrieveResourcesForEachSubscription(subscriptions, resourceQuery) { + var deferred = $q.defer(); + + var resources = {}; + + var resourceQueries = []; + for (var i = 0; i < subscriptions.length; i++) { + var subscription = subscriptions[i]; + resourceQueries.push(resourceQuery(subscription.Id)); + } + + $q.all(resourceQueries) + .then(function success(data) { + for (var i = 0; i < data.length; i++) { + var result = data[i]; + resources[subscriptions[i].Id] = result; + } + deferred.resolve(resources); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve resources', err: err }); + }); + + return deferred.promise; + } + + return service; +}]); diff --git a/app/azure/services/containerGroupService.js b/app/azure/services/containerGroupService.js new file mode 100644 index 000000000..96031a587 --- /dev/null +++ b/app/azure/services/containerGroupService.js @@ -0,0 +1,33 @@ +angular.module('portainer.azure') +.factory('ContainerGroupService', ['$q', 'ContainerGroup', function ContainerGroupServiceFactory($q, ContainerGroup) { + 'use strict'; + var service = {}; + + service.containerGroups = function(subscriptionId) { + var deferred = $q.defer(); + + ContainerGroup.query({ subscriptionId: subscriptionId }).$promise + .then(function success(data) { + var containerGroups = data.value.map(function (item) { + return new ContainerGroupViewModel(item); + }); + deferred.resolve(containerGroups); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve container groups', err: err }); + }); + + return deferred.promise; + }; + + service.create = function(model, subscriptionId, resourceGroupName) { + var payload = new CreateContainerGroupRequest(model); + return ContainerGroup.create({ + subscriptionId: subscriptionId, + resourceGroupName: resourceGroupName, + containerGroupName: model.Name + }, payload).$promise; + }; + + return service; +}]); diff --git a/app/azure/services/locationService.js b/app/azure/services/locationService.js new file mode 100644 index 000000000..547ed93b3 --- /dev/null +++ b/app/azure/services/locationService.js @@ -0,0 +1,24 @@ +angular.module('portainer.azure') +.factory('LocationService', ['$q', 'Location', function LocationServiceFactory($q, Location) { + 'use strict'; + var service = {}; + + service.locations = function(subscriptionId) { + var deferred = $q.defer(); + + Location.query({ subscriptionId: subscriptionId }).$promise + .then(function success(data) { + var locations = data.value.map(function (item) { + return new LocationViewModel(item); + }); + deferred.resolve(locations); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve locations', err: err }); + }); + + return deferred.promise; + }; + + return service; +}]); diff --git a/app/azure/services/providerService.js b/app/azure/services/providerService.js new file mode 100644 index 000000000..88451d4f5 --- /dev/null +++ b/app/azure/services/providerService.js @@ -0,0 +1,22 @@ +angular.module('portainer.azure') +.factory('ProviderService', ['$q', 'Provider', function ProviderServiceFactory($q, Provider) { + 'use strict'; + var service = {}; + + service.containerInstanceProvider = function(subscriptionId) { + var deferred = $q.defer(); + + Provider.get({ subscriptionId: subscriptionId, providerNamespace: 'Microsoft.ContainerInstance' }).$promise + .then(function success(data) { + var provider = new ContainerInstanceProviderViewModel(data); + deferred.resolve(provider); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve provider', err: err }); + }); + + return deferred.promise; + }; + + return service; +}]); diff --git a/app/azure/services/resourceGroupService.js b/app/azure/services/resourceGroupService.js new file mode 100644 index 000000000..1777edea8 --- /dev/null +++ b/app/azure/services/resourceGroupService.js @@ -0,0 +1,24 @@ +angular.module('portainer.azure') +.factory('ResourceGroupService', ['$q', 'ResourceGroup', function ResourceGroupServiceFactory($q, ResourceGroup) { + 'use strict'; + var service = {}; + + service.resourceGroups = function(subscriptionId) { + var deferred = $q.defer(); + + ResourceGroup.query({ subscriptionId: subscriptionId }).$promise + .then(function success(data) { + var resourceGroups = data.value.map(function (item) { + return new ResourceGroupViewModel(item, subscriptionId); + }); + deferred.resolve(resourceGroups); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve resource groups', err: err }); + }); + + return deferred.promise; + }; + + return service; +}]); diff --git a/app/azure/services/subscriptionService.js b/app/azure/services/subscriptionService.js new file mode 100644 index 000000000..f468e1c8e --- /dev/null +++ b/app/azure/services/subscriptionService.js @@ -0,0 +1,24 @@ +angular.module('portainer.azure') +.factory('SubscriptionService', ['$q', 'Subscription', function SubscriptionServiceFactory($q, Subscription) { + 'use strict'; + var service = {}; + + service.subscriptions = function() { + var deferred = $q.defer(); + + Subscription.query({}).$promise + .then(function success(data) { + var subscriptions = data.value.map(function (item) { + return new SubscriptionViewModel(item); + }); + deferred.resolve(subscriptions); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve subscriptions', err: err }); + }); + + return deferred.promise; + }; + + return service; +}]); diff --git a/app/azure/views/containerinstances/containerInstancesController.js b/app/azure/views/containerinstances/containerInstancesController.js new file mode 100644 index 000000000..ecf2c40cd --- /dev/null +++ b/app/azure/views/containerinstances/containerInstancesController.js @@ -0,0 +1,41 @@ +angular.module('portainer.azure') +.controller('AzureContainerInstancesController', ['$scope', '$state', 'AzureService', 'Notifications', +function ($scope, $state, AzureService, Notifications) { + + function initView() { + AzureService.subscriptions() + .then(function success(data) { + var subscriptions = data; + return AzureService.containerGroups(subscriptions); + }) + .then(function success(data) { + $scope.containerGroups = AzureService.aggregate(data); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to load container groups'); + }); + } + + $scope.deleteAction = function (selectedItems) { + var actionCount = selectedItems.length; + angular.forEach(selectedItems, function (item) { + AzureService.deleteContainerGroup(item.Id) + .then(function success() { + Notifications.success('Container group successfully removed', item.Name); + var index = $scope.containerGroups.indexOf(item); + $scope.containerGroups.splice(index, 1); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to remove container group'); + }) + .finally(function final() { + --actionCount; + if (actionCount === 0) { + $state.reload(); + } + }); + }); + }; + + initView(); +}]); diff --git a/app/azure/views/containerinstances/containerinstances.html b/app/azure/views/containerinstances/containerinstances.html new file mode 100644 index 000000000..489d6b807 --- /dev/null +++ b/app/azure/views/containerinstances/containerinstances.html @@ -0,0 +1,19 @@ + + + + + + + Container instances + + +
+
+ +
+
diff --git a/app/azure/views/containerinstances/create/createContainerInstanceController.js b/app/azure/views/containerinstances/create/createContainerInstanceController.js new file mode 100644 index 000000000..b3a7ed173 --- /dev/null +++ b/app/azure/views/containerinstances/create/createContainerInstanceController.js @@ -0,0 +1,87 @@ +angular.module('portainer.azure') +.controller('AzureCreateContainerInstanceController', ['$q', '$scope', '$state', 'AzureService', 'Notifications', +function ($q, $scope, $state, AzureService, Notifications) { + + var allResourceGroups = []; + var allProviders = []; + + $scope.state = { + actionInProgress: false, + selectedSubscription: null, + selectedResourceGroup: null + }; + + $scope.changeSubscription = function() { + var selectedSubscription = $scope.state.selectedSubscription; + updateResourceGroupsAndLocations(selectedSubscription, allResourceGroups, allProviders); + }; + + $scope.addPortBinding = function() { + $scope.model.Ports.push({ host: '', container: '', protocol: 'TCP' }); + }; + + $scope.removePortBinding = function(index) { + $scope.model.Ports.splice(index, 1); + }; + + $scope.create = function() { + var model = $scope.model; + var subscriptionId = $scope.state.selectedSubscription.Id; + var resourceGroupName = $scope.state.selectedResourceGroup.Name; + + $scope.state.actionInProgress = true; + AzureService.createContainerGroup(model, subscriptionId, resourceGroupName) + .then(function success(data) { + Notifications.success('Container successfully created', model.Name); + $state.go('azure.containerinstances'); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to create container'); + }) + .finally(function final() { + $scope.state.actionInProgress = false; + }); + }; + + function updateResourceGroupsAndLocations(subscription, resourceGroups, providers) { + $scope.state.selectedResourceGroup = resourceGroups[subscription.Id][0]; + $scope.resourceGroups = resourceGroups[subscription.Id]; + + var currentSubLocations = providers[subscription.Id].Locations; + $scope.model.Location = currentSubLocations[0]; + $scope.locations = currentSubLocations; + } + + function initView() { + var model = new ContainerGroupDefaultModel(); + + AzureService.subscriptions() + .then(function success(data) { + var subscriptions = data; + $scope.state.selectedSubscription = subscriptions[0]; + $scope.subscriptions = subscriptions; + + return $q.all({ + resourceGroups: AzureService.resourceGroups(subscriptions), + containerInstancesProviders: AzureService.containerInstanceProvider(subscriptions) + }); + }) + .then(function success(data) { + var resourceGroups = data.resourceGroups; + allResourceGroups = resourceGroups; + + var containerInstancesProviders = data.containerInstancesProviders; + allProviders = containerInstancesProviders; + + $scope.model = model; + + var selectedSubscription = $scope.state.selectedSubscription; + updateResourceGroupsAndLocations(selectedSubscription, resourceGroups, containerInstancesProviders); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to retrieve Azure resources'); + }); + } + + initView(); +}]); diff --git a/app/azure/views/containerinstances/create/createcontainerinstance.html b/app/azure/views/containerinstances/create/createcontainerinstance.html new file mode 100644 index 000000000..001461341 --- /dev/null +++ b/app/azure/views/containerinstances/create/createcontainerinstance.html @@ -0,0 +1,160 @@ + + + + Container instances > Add container + + + +
+
+ + +
+
+ Azure settings +
+ +
+ +
+ +
+
+ + +
+ +
+ +
+
+ + +
+ +
+ +
+
+ +
+ Container configuration +
+ +
+ +
+ +
+
+ + +
+ +
+ +
+
+ + +
+ +
+ +
+
+ + +
+
+ + + map additional port + +
+ +
+
+ +
+ host + +
+ + + + + +
+ container + +
+ + +
+
+ + +
+ +
+ +
+
+ +
+ + +
+
+ + +
+
+ +
+ Container resources +
+ +
+ +
+ +
+
+ + +
+ +
+ +
+
+ + +
+ Actions +
+
+
+ +
+
+ +
+
+
+
+
diff --git a/app/azure/views/dashboard/dashboard.html b/app/azure/views/dashboard/dashboard.html new file mode 100644 index 000000000..5726304c1 --- /dev/null +++ b/app/azure/views/dashboard/dashboard.html @@ -0,0 +1,33 @@ + + + Dashboard + + + diff --git a/app/azure/views/dashboard/dashboardController.js b/app/azure/views/dashboard/dashboardController.js new file mode 100644 index 000000000..f24ff4e29 --- /dev/null +++ b/app/azure/views/dashboard/dashboardController.js @@ -0,0 +1,21 @@ +angular.module('portainer.azure') +.controller('AzureDashboardController', ['$scope', 'AzureService', 'Notifications', +function ($scope, AzureService, Notifications) { + + function initView() { + AzureService.subscriptions() + .then(function success(data) { + var subscriptions = data; + $scope.subscriptions = subscriptions; + return AzureService.resourceGroups(subscriptions); + }) + .then(function success(data) { + $scope.resourceGroups = AzureService.aggregate(data); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to load dashboard data'); + }); + } + + initView(); +}]); diff --git a/app/docker/views/containers/edit/containerController.js b/app/docker/views/containers/edit/containerController.js index a0a2f4e60..ad0f261a8 100644 --- a/app/docker/views/containers/edit/containerController.js +++ b/app/docker/views/containers/edit/containerController.js @@ -144,7 +144,7 @@ function ($q, $scope, $state, $transition$, $filter, Commit, ContainerHelper, Co $scope.state.joinNetworkInProgress = false; }); }; - + $scope.commit = function () { var image = $scope.config.Image; var registry = $scope.config.Registry; diff --git a/app/portainer/services/api/endpointService.js b/app/portainer/services/api/endpointService.js index 85e15aa4f..9db9f4165 100644 --- a/app/portainer/services/api/endpointService.js +++ b/app/portainer/services/api/endpointService.js @@ -70,7 +70,7 @@ function EndpointServiceFactory($q, Endpoints, FileUploadService) { service.createLocalEndpoint = function() { var deferred = $q.defer(); - FileUploadService.createEndpoint('local', 'unix:///var/run/docker.sock', '', 1, false) + FileUploadService.createEndpoint('local', 1, 'unix:///var/run/docker.sock', '', 1, false) .then(function success(response) { deferred.resolve(response.data); }) @@ -81,10 +81,10 @@ function EndpointServiceFactory($q, Endpoints, FileUploadService) { return deferred.promise; }; - service.createRemoteEndpoint = function(name, URL, PublicURL, groupID, TLS, TLSSkipVerify, TLSSkipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile) { + service.createRemoteEndpoint = function(name, type, URL, PublicURL, groupID, TLS, TLSSkipVerify, TLSSkipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile) { var deferred = $q.defer(); - FileUploadService.createEndpoint(name, 'tcp://' + URL, PublicURL, groupID, TLS, TLSSkipVerify, TLSSkipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile) + FileUploadService.createEndpoint(name, type, 'tcp://' + URL, PublicURL, groupID, TLS, TLSSkipVerify, TLSSkipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile) .then(function success(response) { deferred.resolve(response.data); }) @@ -95,5 +95,19 @@ function EndpointServiceFactory($q, Endpoints, FileUploadService) { return deferred.promise; }; + service.createAzureEndpoint = function(name, applicationId, tenantId, authenticationKey) { + var deferred = $q.defer(); + + FileUploadService.createAzureEndpoint(name, applicationId, tenantId, authenticationKey) + .then(function success(response) { + deferred.resolve(response.data); + }) + .catch(function error(err) { + deferred.reject({msg: 'Unable to connect to Azure', err: err}); + }); + + return deferred.promise; + }; + return service; }]); diff --git a/app/portainer/services/fileUpload.js b/app/portainer/services/fileUpload.js index d2c2120d6..c5413149c 100644 --- a/app/portainer/services/fileUpload.js +++ b/app/portainer/services/fileUpload.js @@ -42,11 +42,12 @@ angular.module('portainer.app') }); }; - service.createEndpoint = function(name, URL, PublicURL, groupID, TLS, TLSSkipVerify, TLSSkipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile) { + service.createEndpoint = function(name, type, URL, PublicURL, groupID, TLS, TLSSkipVerify, TLSSkipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile) { return Upload.upload({ url: 'api/endpoints', data: { Name: name, + EndpointType: type, URL: URL, PublicURL: PublicURL, GroupID: groupID, @@ -61,6 +62,20 @@ angular.module('portainer.app') }); }; + service.createAzureEndpoint = function(name, applicationId, tenantId, authenticationKey) { + return Upload.upload({ + url: 'api/endpoints', + data: { + Name: name, + EndpointType: 3, + AzureApplicationID: applicationId, + AzureTenantID: tenantId, + AzureAuthenticationKey: authenticationKey + }, + ignoreLoadingBar: true + }); + }; + service.uploadLDAPTLSFiles = function(TLSCAFile, TLSCertFile, TLSKeyFile) { var queue = []; diff --git a/app/portainer/services/stateManager.js b/app/portainer/services/stateManager.js index c7322a4c3..f50101a94 100644 --- a/app/portainer/services/stateManager.js +++ b/app/portainer/services/stateManager.js @@ -128,6 +128,14 @@ function StateManagerFactory($q, SystemService, InfoHelper, LocalStorage, Settin if (loading) { state.loading = true; } + + if (type === 3) { + state.endpoint.mode = { provider: 'AZURE' }; + LocalStorage.storeEndpointState(state.endpoint); + deferred.resolve(); + return deferred.promise; + } + $q.all({ version: SystemService.version(), info: SystemService.info() diff --git a/app/portainer/views/auth/authController.js b/app/portainer/views/auth/authController.js index 7b53dfe91..ab65f2163 100644 --- a/app/portainer/views/auth/authController.js +++ b/app/portainer/views/auth/authController.js @@ -13,12 +13,7 @@ function ($scope, $state, $transition$, $window, $timeout, $sanitize, Authentica AuthenticationError: '' }; - function setActiveEndpointAndRedirectToDashboard(endpoint) { - var endpointID = EndpointProvider.endpointID(); - if (!endpointID) { - EndpointProvider.setEndpointID(endpoint.Id); - } - + function redirectToDockerDashboard(endpoint) { ExtensionManager.initEndpointExtensions(endpoint.Id) .then(function success(data) { var extensions = data; @@ -32,12 +27,31 @@ function ($scope, $state, $transition$, $window, $timeout, $sanitize, Authentica }); } + function redirectToAzureDashboard(endpoint) { + StateManager.updateEndpointState(false, endpoint.Type, []) + .then(function success(data) { + $state.go('azure.dashboard'); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to connect to the Docker endpoint'); + }); + } + + function redirectToDashboard(endpoint) { + EndpointProvider.setEndpointID(endpoint.Id); + + if (endpoint.Type === 3) { + return redirectToAzureDashboard(endpoint); + } + redirectToDockerDashboard(endpoint); + } + function unauthenticatedFlow() { EndpointService.endpoints() .then(function success(data) { var endpoints = data; if (endpoints.length > 0) { - setActiveEndpointAndRedirectToDashboard(endpoints[0]); + redirectToDashboard(endpoints[0]); } else { $state.go('portainer.init.endpoint'); } @@ -79,7 +93,7 @@ function ($scope, $state, $transition$, $window, $timeout, $sanitize, Authentica var endpoints = data; var userDetails = Authentication.getUserDetails(); if (endpoints.length > 0) { - setActiveEndpointAndRedirectToDashboard(endpoints[0]); + redirectToDashboard(endpoints[0]); } else if (endpoints.length === 0 && userDetails.role === 1) { $state.go('portainer.init.endpoint'); } else if (endpoints.length === 0 && userDetails.role === 2) { diff --git a/app/portainer/views/endpoints/create/createEndpointController.js b/app/portainer/views/endpoints/create/createEndpointController.js index bda06746d..3418f251a 100644 --- a/app/portainer/views/endpoints/create/createEndpointController.js +++ b/app/portainer/views/endpoints/create/createEndpointController.js @@ -12,7 +12,10 @@ function ($scope, $state, $filter, EndpointService, GroupService, Notifications) URL: '', PublicURL: '', GroupId: 1, - SecurityFormData: new EndpointSecurityFormData() + SecurityFormData: new EndpointSecurityFormData(), + AzureApplicationId: '', + AzureTenantId: '', + AzureAuthenticationKey: '' }; $scope.addDockerEndpoint = function() { @@ -30,7 +33,7 @@ function ($scope, $state, $filter, EndpointService, GroupService, Notifications) var TLSCertFile = TLSSkipClientVerify ? null : securityData.TLSCert; var TLSKeyFile = TLSSkipClientVerify ? null : securityData.TLSKey; - addEndpoint(name, URL, publicURL, groupId, TLS, TLSSkipVerify, TLSSkipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile); + addEndpoint(name, 1, URL, publicURL, groupId, TLS, TLSSkipVerify, TLSSkipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile); }; $scope.addAgentEndpoint = function() { @@ -39,12 +42,38 @@ function ($scope, $state, $filter, EndpointService, GroupService, Notifications) var publicURL = $scope.formValues.PublicURL === '' ? URL.split(':')[0] : $scope.formValues.PublicURL; var groupId = $scope.formValues.GroupId; - addEndpoint(name, URL, publicURL, groupId, true, true, true, null, null, null); + addEndpoint(name, 2, URL, publicURL, groupId, true, true, true, null, null, null); }; - function addEndpoint(name, URL, PublicURL, groupId, TLS, TLSSkipVerify, TLSSkipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile) { + $scope.addAzureEndpoint = function() { + var name = $scope.formValues.Name; + var applicationId = $scope.formValues.AzureApplicationId; + var tenantId = $scope.formValues.AzureTenantId; + var authenticationKey = $scope.formValues.AzureAuthenticationKey; + + createAzureEndpoint(name, applicationId, tenantId, authenticationKey); + }; + + function createAzureEndpoint(name, applicationId, tenantId, authenticationKey) { + var endpoint; + $scope.state.actionInProgress = true; - EndpointService.createRemoteEndpoint(name, URL, PublicURL, groupId, TLS, TLSSkipVerify, TLSSkipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile) + EndpointService.createAzureEndpoint(name, applicationId, tenantId, authenticationKey) + .then(function success() { + Notifications.success('Endpoint created', name); + $state.go('portainer.endpoints', {}, {reload: true}); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to create endpoint'); + }) + .finally(function final() { + $scope.state.actionInProgress = false; + }); + } + + function addEndpoint(name, type, URL, PublicURL, groupId, TLS, TLSSkipVerify, TLSSkipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile) { + $scope.state.actionInProgress = true; + EndpointService.createRemoteEndpoint(name, type, URL, PublicURL, groupId, TLS, TLSSkipVerify, TLSSkipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile) .then(function success() { Notifications.success('Endpoint created', name); $state.go('portainer.endpoints', {}, {reload: true}); diff --git a/app/portainer/views/endpoints/create/createendpoint.html b/app/portainer/views/endpoints/create/createendpoint.html index 1ab647c4b..e40976bcb 100644 --- a/app/portainer/views/endpoints/create/createendpoint.html +++ b/app/portainer/views/endpoints/create/createendpoint.html @@ -36,6 +36,16 @@

Portainer agent

+
+ + +
@@ -59,6 +69,28 @@
+
+
+ Information +
+
+
+ +

+ This feature is experimental. +

+

+ Connect to Microsoft Azure to manage Azure Container Instances (ACI). +

+

+ + Have a look at the Azure documentation to retrieve + the credentials required below. +

+
+
+
+
Environment details
@@ -78,35 +110,88 @@ -
- -
- - +
+
+ +
+ + +
-
-
-
-
-

This field is required.

+
+
+
+

This field is required.

+
-
- -
- +
+
+ +
+ +
+ +
+ +
+ +
+ +
+
+
+
+
+

This field is required.

+
+
+
+ + +
+ +
+ +
+
+
+
+
+

This field is required.

+
+
+
+ + +
+ +
+ +
+
+
+
+
+

This field is required.

+
+
+
+ +
+
diff --git a/app/portainer/views/init/endpoint/initEndpoint.html b/app/portainer/views/init/endpoint/initEndpoint.html index cbaeac7f5..957fd7010 100644 --- a/app/portainer/views/init/endpoint/initEndpoint.html +++ b/app/portainer/views/init/endpoint/initEndpoint.html @@ -1,7 +1,7 @@
-
+
@@ -55,6 +55,16 @@

Connect to a Portainer agent

+
+ + +
@@ -141,6 +151,78 @@
+ +
+
+ Information +
+
+
+ +

+ This feature is experimental. +

+

+ Connect to Microsoft Azure to manage Azure Container Instances (ACI). +

+

+ + Have a look at the Azure documentation to retrieve + the credentials required below. +

+
+
+
+
+ Environment +
+ +
+ +
+ +
+
+ +
+ Azure credentials +
+ +
+ +
+ +
+
+ + +
+ +
+ +
+
+ + +
+ +
+ +
+
+ + +
+
+ +
+
+ +
+
diff --git a/app/portainer/views/init/endpoint/initEndpointController.js b/app/portainer/views/init/endpoint/initEndpointController.js index 67d9da7b1..6af86fbd3 100644 --- a/app/portainer/views/init/endpoint/initEndpointController.js +++ b/app/portainer/views/init/endpoint/initEndpointController.js @@ -22,7 +22,10 @@ function ($scope, $state, EndpointService, StateManager, EndpointProvider, Notif TLSSKipClientVerify: false, TLSCACert: null, TLSCert: null, - TLSKey: null + TLSKey: null, + AzureApplicationId: '', + AzureTenantId: '', + AzureAuthenticationKey: '' }; $scope.createLocalEndpoint = function() { @@ -52,12 +55,21 @@ function ($scope, $state, EndpointService, StateManager, EndpointProvider, Notif }); }; + $scope.createAzureEndpoint = function() { + var name = $scope.formValues.Name; + var applicationId = $scope.formValues.AzureApplicationId; + var tenantId = $scope.formValues.AzureTenantId; + var authenticationKey = $scope.formValues.AzureAuthenticationKey; + + createAzureEndpoint(name, applicationId, tenantId, authenticationKey); + }; + $scope.createAgentEndpoint = function() { var name = $scope.formValues.Name; var URL = $scope.formValues.URL; var PublicURL = URL.split(':')[0]; - createRemoteEndpoint(name, URL, PublicURL, true, true, true, null, null, null); + createRemoteEndpoint(name, 2, URL, PublicURL, true, true, true, null, null, null); }; $scope.createRemoteEndpoint = function() { @@ -71,13 +83,34 @@ function ($scope, $state, EndpointService, StateManager, EndpointProvider, Notif var TLSCertFile = TLSSKipClientVerify ? null : $scope.formValues.TLSCert; var TLSKeyFile = TLSSKipClientVerify ? null : $scope.formValues.TLSKey; - createRemoteEndpoint(name, URL, PublicURL, TLS, TLSSkipVerify, TLSSKipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile); + createRemoteEndpoint(name, 1, URL, PublicURL, TLS, TLSSkipVerify, TLSSKipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile); }; + function createAzureEndpoint(name, applicationId, tenantId, authenticationKey) { + var endpoint; + + $scope.state.actionInProgress = true; + EndpointService.createAzureEndpoint(name, applicationId, tenantId, authenticationKey) + .then(function success(data) { + endpoint = data; + EndpointProvider.setEndpointID(endpoint.Id); + return StateManager.updateEndpointState(false, endpoint.Type, []); + }) + .then(function success(data) { + $state.go('azure.dashboard'); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to connect to the Azure environment'); + }) + .finally(function final() { + $scope.state.actionInProgress = false; + }); + } + function createRemoteEndpoint(name, URL, PublicURL, TLS, TLSSkipVerify, TLSSKipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile) { var endpoint; $scope.state.actionInProgress = true; - EndpointService.createRemoteEndpoint(name, URL, PublicURL, 1, TLS, TLSSkipVerify, TLSSKipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile) + EndpointService.createRemoteEndpoint(name, type, URL, PublicURL, 1, TLS, TLSSkipVerify, TLSSKipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile) .then(function success(data) { endpoint = data; EndpointProvider.setEndpointID(endpoint.Id); diff --git a/app/portainer/views/sidebar/sidebar.html b/app/portainer/views/sidebar/sidebar.html index e182e5a01..f399ed454 100644 --- a/app/portainer/views/sidebar/sidebar.html +++ b/app/portainer/views/sidebar/sidebar.html @@ -15,7 +15,9 @@ select-endpoint="switchEndpoint" > - + + Date: Mon, 28 May 2018 16:40:59 +0200 Subject: [PATCH 05/37] feat(support): add support view (#1937) --- api/cmd/portainer/main.go | 1 - api/http/handler/settings.go | 4 -- api/portainer.go | 3 +- api/swagger.yaml | 12 ------ app/portainer/__module.js | 11 ++++++ app/portainer/components/header-title.js | 3 +- app/portainer/models/settings/settings.js | 1 - app/portainer/services/stateManager.js | 6 --- app/portainer/views/about/about.html | 8 ---- app/portainer/views/settings/settings.html | 10 ----- .../views/settings/settingsController.js | 4 -- app/portainer/views/support/support.html | 38 +++++++++++++++++++ 12 files changed, 52 insertions(+), 49 deletions(-) create mode 100644 app/portainer/views/support/support.html diff --git a/api/cmd/portainer/main.go b/api/cmd/portainer/main.go index 3c2dba7c6..2ed222d33 100644 --- a/api/cmd/portainer/main.go +++ b/api/cmd/portainer/main.go @@ -139,7 +139,6 @@ func initSettings(settingsService portainer.SettingsService, flags *portainer.CL if err == portainer.ErrSettingsNotFound { settings := &portainer.Settings{ LogoURL: *flags.Logo, - DisplayDonationHeader: true, DisplayExternalContributors: false, AuthenticationMethod: portainer.AuthenticationInternal, LDAPSettings: portainer.LDAPSettings{ diff --git a/api/http/handler/settings.go b/api/http/handler/settings.go index bef01db91..2df31b62e 100644 --- a/api/http/handler/settings.go +++ b/api/http/handler/settings.go @@ -46,7 +46,6 @@ func NewSettingsHandler(bouncer *security.RequestBouncer) *SettingsHandler { type ( publicSettingsResponse struct { LogoURL string `json:"LogoURL"` - DisplayDonationHeader bool `json:"DisplayDonationHeader"` DisplayExternalContributors bool `json:"DisplayExternalContributors"` AuthenticationMethod portainer.AuthenticationMethod `json:"AuthenticationMethod"` AllowBindMountsForRegularUsers bool `json:"AllowBindMountsForRegularUsers"` @@ -57,7 +56,6 @@ type ( TemplatesURL string `valid:"required"` LogoURL string `valid:""` BlackListedLabels []portainer.Pair `valid:""` - DisplayDonationHeader bool `valid:""` DisplayExternalContributors bool `valid:""` AuthenticationMethod int `valid:"required"` LDAPSettings portainer.LDAPSettings `valid:""` @@ -92,7 +90,6 @@ func (handler *SettingsHandler) handleGetPublicSettings(w http.ResponseWriter, r publicSettings := &publicSettingsResponse{ LogoURL: settings.LogoURL, - DisplayDonationHeader: settings.DisplayDonationHeader, DisplayExternalContributors: settings.DisplayExternalContributors, AuthenticationMethod: settings.AuthenticationMethod, AllowBindMountsForRegularUsers: settings.AllowBindMountsForRegularUsers, @@ -121,7 +118,6 @@ func (handler *SettingsHandler) handlePutSettings(w http.ResponseWriter, r *http TemplatesURL: req.TemplatesURL, LogoURL: req.LogoURL, BlackListedLabels: req.BlackListedLabels, - DisplayDonationHeader: req.DisplayDonationHeader, DisplayExternalContributors: req.DisplayExternalContributors, LDAPSettings: req.LDAPSettings, AllowBindMountsForRegularUsers: req.AllowBindMountsForRegularUsers, diff --git a/api/portainer.go b/api/portainer.go index 413b37d39..e064285fa 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -73,12 +73,13 @@ type ( TemplatesURL string `json:"TemplatesURL"` LogoURL string `json:"LogoURL"` BlackListedLabels []Pair `json:"BlackListedLabels"` - DisplayDonationHeader bool `json:"DisplayDonationHeader"` DisplayExternalContributors bool `json:"DisplayExternalContributors"` AuthenticationMethod AuthenticationMethod `json:"AuthenticationMethod"` LDAPSettings LDAPSettings `json:"LDAPSettings"` AllowBindMountsForRegularUsers bool `json:"AllowBindMountsForRegularUsers"` AllowPrivilegedModeForRegularUsers bool `json:"AllowPrivilegedModeForRegularUsers"` + // Deprecated fields + DisplayDonationHeader bool } // User represents a user account. diff --git a/api/swagger.yaml b/api/swagger.yaml index edacf59d7..481a99930 100644 --- a/api/swagger.yaml +++ b/api/swagger.yaml @@ -2187,10 +2187,6 @@ definitions: description: "URL to a logo that will be displayed on the login page as well\ \ as on top of the sidebar. Will use default Portainer logo when value is\ \ empty string" - DisplayDonationHeader: - type: "boolean" - example: true - description: "Whether to display or not the donation message in the header." DisplayExternalContributors: type: "boolean" example: false @@ -2294,10 +2290,6 @@ definitions: \ when querying containers" items: $ref: "#/definitions/Settings_BlackListedLabels" - DisplayDonationHeader: - type: "boolean" - example: true - description: "Whether to display or not the donation message in the header." DisplayExternalContributors: type: "boolean" example: false @@ -2688,10 +2680,6 @@ definitions: \ when querying containers" items: $ref: "#/definitions/Settings_BlackListedLabels" - DisplayDonationHeader: - type: "boolean" - example: true - description: "Whether to display or not the donation message in the header." DisplayExternalContributors: type: "boolean" example: false diff --git a/app/portainer/__module.js b/app/portainer/__module.js index 77caaa70c..2b017bb2b 100644 --- a/app/portainer/__module.js +++ b/app/portainer/__module.js @@ -253,6 +253,16 @@ angular.module('portainer.app', []) } }; + var support = { + name: 'portainer.support', + url: '/support', + views: { + 'content@': { + templateUrl: 'app/portainer/views/support/support.html' + } + } + }; + var users = { name: 'portainer.users', url: '/users', @@ -319,6 +329,7 @@ angular.module('portainer.app', []) $stateRegistryProvider.register(registryCreation); $stateRegistryProvider.register(settings); $stateRegistryProvider.register(settingsAuthentication); + $stateRegistryProvider.register(support); $stateRegistryProvider.register(users); $stateRegistryProvider.register(user); $stateRegistryProvider.register(teams); diff --git a/app/portainer/components/header-title.js b/app/portainer/components/header-title.js index 92a6e3d4c..e2eaa1945 100644 --- a/app/portainer/components/header-title.js +++ b/app/portainer/components/header-title.js @@ -7,10 +7,9 @@ angular.module('portainer.app') }, link: function (scope, iElement, iAttrs) { scope.username = Authentication.getUserDetails().username; - scope.displayDonationHeader = StateManager.getState().application.displayDonationHeader; }, transclude: true, - template: '
{{title}} {{username}} Help support portainer
', + template: '
{{title}} {{username}} Portainer support
', restrict: 'E' }; return directive; diff --git a/app/portainer/models/settings/settings.js b/app/portainer/models/settings/settings.js index 300de0733..4c130b052 100644 --- a/app/portainer/models/settings/settings.js +++ b/app/portainer/models/settings/settings.js @@ -2,7 +2,6 @@ function SettingsViewModel(data) { this.TemplatesURL = data.TemplatesURL; this.LogoURL = data.LogoURL; this.BlackListedLabels = data.BlackListedLabels; - this.DisplayDonationHeader = data.DisplayDonationHeader; this.DisplayExternalContributors = data.DisplayExternalContributors; this.AuthenticationMethod = data.AuthenticationMethod; this.LDAPSettings = data.LDAPSettings; diff --git a/app/portainer/services/stateManager.js b/app/portainer/services/stateManager.js index f50101a94..d27844e60 100644 --- a/app/portainer/services/stateManager.js +++ b/app/portainer/services/stateManager.js @@ -30,18 +30,12 @@ function StateManagerFactory($q, SystemService, InfoHelper, LocalStorage, Settin LocalStorage.storeApplicationState(state.application); }; - manager.updateDonationHeader = function(displayDonationHeader) { - state.application.displayDonationHeader = displayDonationHeader; - LocalStorage.storeApplicationState(state.application); - }; - function assignStateFromStatusAndSettings(status, settings) { state.application.authentication = status.Authentication; state.application.analytics = status.Analytics; state.application.endpointManagement = status.EndpointManagement; state.application.version = status.Version; state.application.logo = settings.LogoURL; - state.application.displayDonationHeader = settings.DisplayDonationHeader; state.application.displayExternalContributors = settings.DisplayExternalContributors; state.application.validity = moment().unix(); } diff --git a/app/portainer/views/about/about.html b/app/portainer/views/about/about.html index 6486ad264..96bca4322 100644 --- a/app/portainer/views/about/about.html +++ b/app/portainer/views/about/about.html @@ -25,14 +25,6 @@

It is a community effort to make Portainer as feature-rich as simple it is to deploy and use. We need all the help we can get!

-

- Fund our work -

-

Contribute

    diff --git a/app/portainer/views/settings/settings.html b/app/portainer/views/settings/settings.html index cc5b0b50a..dc9671746 100644 --- a/app/portainer/views/settings/settings.html +++ b/app/portainer/views/settings/settings.html @@ -9,16 +9,6 @@
    -
    -
    - - -
    -
    diff --git a/app/portainer/views/settings/settingsController.js b/app/portainer/views/settings/settingsController.js index bc0623bc9..f21d28446 100644 --- a/app/portainer/views/settings/settingsController.js +++ b/app/portainer/views/settings/settingsController.js @@ -9,7 +9,6 @@ function ($scope, $state, Notifications, SettingsService, StateManager, DEFAULT_ $scope.formValues = { customLogo: false, customTemplates: false, - donationHeader: true, externalContributions: false, restrictBindMounts: false, restrictPrivilegedMode: false, @@ -46,7 +45,6 @@ function ($scope, $state, Notifications, SettingsService, StateManager, DEFAULT_ settings.TemplatesURL = DEFAULT_TEMPLATES_URL; } - settings.DisplayDonationHeader = !$scope.formValues.donationHeader; settings.DisplayExternalContributors = !$scope.formValues.externalContributions; settings.AllowBindMountsForRegularUsers = !$scope.formValues.restrictBindMounts; settings.AllowPrivilegedModeForRegularUsers = !$scope.formValues.restrictPrivilegedMode; @@ -60,7 +58,6 @@ function ($scope, $state, Notifications, SettingsService, StateManager, DEFAULT_ .then(function success(data) { Notifications.success('Settings updated'); StateManager.updateLogo(settings.LogoURL); - StateManager.updateDonationHeader(settings.DisplayDonationHeader); StateManager.updateExternalContributions(settings.DisplayExternalContributors); $state.reload(); }) @@ -83,7 +80,6 @@ function ($scope, $state, Notifications, SettingsService, StateManager, DEFAULT_ if (settings.TemplatesURL !== DEFAULT_TEMPLATES_URL) { $scope.formValues.customTemplates = true; } - $scope.formValues.donationHeader = !settings.DisplayDonationHeader; $scope.formValues.externalContributions = !settings.DisplayExternalContributors; $scope.formValues.restrictBindMounts = !settings.AllowBindMountsForRegularUsers; $scope.formValues.restrictPrivilegedMode = !settings.AllowPrivilegedModeForRegularUsers; diff --git a/app/portainer/views/support/support.html b/app/portainer/views/support/support.html new file mode 100644 index 000000000..cbedc28cb --- /dev/null +++ b/app/portainer/views/support/support.html @@ -0,0 +1,38 @@ + + + + + Portainer support + + + +
    +
    + + + +
    +

    + Portainer.io offers multiple commercial support options. +

    +

    + Per incident +

    +

    +

    + Per Portainer instance +

      +
    • $USD 1200 per year
    • +
    • Unlimited incidents
    • +
    • 4 named users
    • +
    • Contact us
    • +
    +

    +
    +
    +
    +
    +
    From c4576e9e2f1e9874a47d664af535e393b89b9519 Mon Sep 17 00:00:00 2001 From: valkheim Date: Thu, 31 May 2018 21:24:15 +0200 Subject: [PATCH 06/37] feat(api): update admin deletion policy (#1935) --- api/errors.go | 1 - api/http/handler/user.go | 5 ----- 2 files changed, 6 deletions(-) diff --git a/api/errors.go b/api/errors.go index 709bafb92..854cefbab 100644 --- a/api/errors.go +++ b/api/errors.go @@ -15,7 +15,6 @@ const ( ErrUserAlreadyExists = Error("User already exists") ErrInvalidUsername = Error("Invalid username. White spaces are not allowed") ErrAdminAlreadyInitialized = Error("An administrator user already exists") - ErrCannotRemoveAdmin = Error("Cannot remove the default administrator account") ErrAdminCannotRemoveSelf = Error("Cannot remove your own user account. Contact another administrator") ) diff --git a/api/http/handler/user.go b/api/http/handler/user.go index 72952737d..d4f34d9b4 100644 --- a/api/http/handler/user.go +++ b/api/http/handler/user.go @@ -397,11 +397,6 @@ func (handler *UserHandler) handleDeleteUser(w http.ResponseWriter, r *http.Requ return } - if userID == 1 { - httperror.WriteErrorResponse(w, portainer.ErrCannotRemoveAdmin, http.StatusForbidden, handler.Logger) - return - } - tokenData, err := security.RetrieveTokenData(r) if err != nil { httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) From e15856c62c52320cdf4edc9dcce6c76bab7bc9cd Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Thu, 31 May 2018 22:00:18 +0200 Subject: [PATCH 07/37] fix(init-endpoint): fix an issue preventing the init of a remote endpoint --- app/portainer/views/init/endpoint/initEndpointController.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/portainer/views/init/endpoint/initEndpointController.js b/app/portainer/views/init/endpoint/initEndpointController.js index 6af86fbd3..69aaa4d63 100644 --- a/app/portainer/views/init/endpoint/initEndpointController.js +++ b/app/portainer/views/init/endpoint/initEndpointController.js @@ -107,7 +107,7 @@ function ($scope, $state, EndpointService, StateManager, EndpointProvider, Notif }); } - function createRemoteEndpoint(name, URL, PublicURL, TLS, TLSSkipVerify, TLSSKipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile) { + function createRemoteEndpoint(name, type, URL, PublicURL, TLS, TLSSkipVerify, TLSSKipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile) { var endpoint; $scope.state.actionInProgress = true; EndpointService.createRemoteEndpoint(name, type, URL, PublicURL, 1, TLS, TLSSkipVerify, TLSSKipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile) From 1cc31f8956f83b4a753f1c9ca55f8dedd16ffa6c Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Fri, 1 Jun 2018 09:09:36 +0200 Subject: [PATCH 08/37] fix(app): fix a state URL conflict between azure and docker modules --- app/azure/_module.js | 1 + 1 file changed, 1 insertion(+) diff --git a/app/azure/_module.js b/app/azure/_module.js index 0879e9a54..23693574c 100644 --- a/app/azure/_module.js +++ b/app/azure/_module.js @@ -4,6 +4,7 @@ angular.module('portainer.azure', ['portainer.app']) var azure = { name: 'azure', + url: '/azure', parent: 'root', abstract: true }; From bfc49574b7bd872d802e862c952d483e2e8c5051 Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Fri, 1 Jun 2018 09:11:56 +0200 Subject: [PATCH 09/37] style(endpoints): update Azure endpoint type description --- app/portainer/views/endpoints/create/createendpoint.html | 2 +- app/portainer/views/init/endpoint/initEndpoint.html | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/portainer/views/endpoints/create/createendpoint.html b/app/portainer/views/endpoints/create/createendpoint.html index e40976bcb..3afc1f55e 100644 --- a/app/portainer/views/endpoints/create/createendpoint.html +++ b/app/portainer/views/endpoints/create/createendpoint.html @@ -43,7 +43,7 @@ Azure
    -

    Connect to Microsoft Azure

    +

    Connect to Microsoft Azure ACI

diff --git a/app/portainer/views/init/endpoint/initEndpoint.html b/app/portainer/views/init/endpoint/initEndpoint.html index 957fd7010..7de38d64e 100644 --- a/app/portainer/views/init/endpoint/initEndpoint.html +++ b/app/portainer/views/init/endpoint/initEndpoint.html @@ -62,7 +62,7 @@ Azure
-

Connect to Microsoft Azure

+

Connect to Microsoft Azure ACI

From 9bb885629ad7a38f7f27540913e6b03bce8c9c97 Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Fri, 1 Jun 2018 16:13:24 +0200 Subject: [PATCH 10/37] feat(endpoints): UX enhancements (#1943) * feat(endpoints): add details about endpoints in datatable * feat(endpoint-details): add the ability to inspect/update azure endpoint * feat(endpoint-selector): disable placeholder selection --- api/http/handler/endpoint.go | 33 +++++++++++++------ api/http/proxy/factory.go | 5 +-- .../azure-endpoint-config.js | 8 +++++ .../azureEndpointConfig.html | 29 ++++++++++++++++ .../endpointsDatatable.html | 13 ++++++++ .../endpoint-selector/endpointSelector.html | 4 +-- app/portainer/filters/filters.js | 22 +++++++++++++ app/portainer/services/api/endpointService.js | 19 ++--------- .../views/endpoints/edit/endpoint.html | 11 +++++-- .../endpoints/edit/endpointController.js | 17 ++++++---- 10 files changed, 122 insertions(+), 39 deletions(-) create mode 100644 app/azure/components/azure-endpoint-config/azure-endpoint-config.js create mode 100644 app/azure/components/azure-endpoint-config/azureEndpointConfig.html diff --git a/api/http/handler/endpoint.go b/api/http/handler/endpoint.go index 972f766cf..4b6a43220 100644 --- a/api/http/handler/endpoint.go +++ b/api/http/handler/endpoint.go @@ -68,13 +68,16 @@ type ( } putEndpointsRequest struct { - Name string `valid:"-"` - URL string `valid:"-"` - PublicURL string `valid:"-"` - GroupID int `valid:"-"` - TLS bool `valid:"-"` - TLSSkipVerify bool `valid:"-"` - TLSSkipClientVerify bool `valid:"-"` + Name string `valid:"-"` + URL string `valid:"-"` + PublicURL string `valid:"-"` + GroupID int `valid:"-"` + TLS bool `valid:"-"` + TLSSkipVerify bool `valid:"-"` + TLSSkipClientVerify bool `valid:"-"` + AzureApplicationID string `valid:"-"` + AzureTenantID string `valid:"-"` + AzureAuthenticationKey string `valid:"-"` } postEndpointPayload struct { @@ -143,7 +146,7 @@ func (handler *EndpointHandler) createAzureEndpoint(payload *postEndpointPayload endpoint := &portainer.Endpoint{ Name: payload.name, - URL: payload.url, + URL: proxy.AzureAPIBaseURL, Type: portainer.AzureEnvironment, GroupID: portainer.EndpointGroupID(payload.groupID), PublicURL: payload.publicURL, @@ -405,8 +408,6 @@ func (handler *EndpointHandler) handleGetEndpoint(w http.ResponseWriter, r *http return } - endpoint.AzureCredentials = portainer.AzureCredentials{} - encodeJSON(w, endpoint, handler.Logger) } @@ -518,6 +519,18 @@ func (handler *EndpointHandler) handlePutEndpoint(w http.ResponseWriter, r *http endpoint.GroupID = portainer.EndpointGroupID(req.GroupID) } + if endpoint.Type == portainer.AzureEnvironment { + if req.AzureApplicationID != "" { + endpoint.AzureCredentials.ApplicationID = req.AzureApplicationID + } + if req.AzureTenantID != "" { + endpoint.AzureCredentials.TenantID = req.AzureTenantID + } + if req.AzureAuthenticationKey != "" { + endpoint.AzureCredentials.AuthenticationKey = req.AzureAuthenticationKey + } + } + folder := strconv.Itoa(int(endpoint.ID)) if req.TLS { endpoint.TLSConfig.TLS = true diff --git a/api/http/proxy/factory.go b/api/http/proxy/factory.go index 70ba4543d..f3c43510f 100644 --- a/api/http/proxy/factory.go +++ b/api/http/proxy/factory.go @@ -10,7 +10,8 @@ import ( "github.com/portainer/portainer/crypto" ) -const azureAPIBaseURL = "https://management.azure.com" +// AzureAPIBaseURL is the URL where Azure API requests will be proxied. +const AzureAPIBaseURL = "https://management.azure.com" // proxyFactory is a factory to create reverse proxies to Docker endpoints type proxyFactory struct { @@ -28,7 +29,7 @@ func (factory *proxyFactory) newHTTPProxy(u *url.URL) http.Handler { } func newAzureProxy(credentials *portainer.AzureCredentials) (http.Handler, error) { - url, err := url.Parse(azureAPIBaseURL) + url, err := url.Parse(AzureAPIBaseURL) if err != nil { return nil, err } diff --git a/app/azure/components/azure-endpoint-config/azure-endpoint-config.js b/app/azure/components/azure-endpoint-config/azure-endpoint-config.js new file mode 100644 index 000000000..b5af18fb7 --- /dev/null +++ b/app/azure/components/azure-endpoint-config/azure-endpoint-config.js @@ -0,0 +1,8 @@ +angular.module('portainer.azure').component('azureEndpointConfig', { + bindings: { + applicationId: '=', + tenantId: '=', + authenticationKey: '=' + }, + templateUrl: 'app/azure/components/azure-endpoint-config/azureEndpointConfig.html' +}); diff --git a/app/azure/components/azure-endpoint-config/azureEndpointConfig.html b/app/azure/components/azure-endpoint-config/azureEndpointConfig.html new file mode 100644 index 000000000..c0d839102 --- /dev/null +++ b/app/azure/components/azure-endpoint-config/azureEndpointConfig.html @@ -0,0 +1,29 @@ +
+
+ Azure configuration +
+ +
+ +
+ +
+
+ + +
+ +
+ +
+
+ + +
+ +
+ +
+
+ +
diff --git a/app/portainer/components/datatables/endpoints-datatable/endpointsDatatable.html b/app/portainer/components/datatables/endpoints-datatable/endpointsDatatable.html index c22dfdc5e..b18084d6e 100644 --- a/app/portainer/components/datatables/endpoints-datatable/endpointsDatatable.html +++ b/app/portainer/components/datatables/endpoints-datatable/endpointsDatatable.html @@ -39,6 +39,13 @@ + + + Type + + + + URL @@ -66,6 +73,12 @@ {{ item.Name }} {{ item.Name }} + + + + {{ item.Type | endpointtypename }} + + {{ item.URL | stripprotocol }} {{ item.GroupName }} diff --git a/app/portainer/components/endpoint-selector/endpointSelector.html b/app/portainer/components/endpoint-selector/endpointSelector.html index eddbc3e55..79332e2b0 100644 --- a/app/portainer/components/endpoint-selector/endpointSelector.html +++ b/app/portainer/components/endpoint-selector/endpointSelector.html @@ -11,7 +11,7 @@
@@ -19,7 +19,7 @@
diff --git a/app/portainer/filters/filters.js b/app/portainer/filters/filters.js index a72ab82cc..bda52fe63 100644 --- a/app/portainer/filters/filters.js +++ b/app/portainer/filters/filters.js @@ -102,6 +102,28 @@ angular.module('portainer.app') return ''; }; }) +.filter('endpointtypename', function () { + 'use strict'; + return function (type) { + if (type === 1) { + return 'Docker'; + } else if (type === 2) { + return 'Agent'; + } else if (type === 3) { + return 'Azure ACI'; + } + return ''; + }; +}) +.filter('endpointtypeicon', function () { + 'use strict'; + return function (type) { + if (type === 3) { + return 'fab fa-microsoft'; + } + return 'fab fa-docker'; + }; +}) .filter('ownershipicon', function () { 'use strict'; return function (ownership) { diff --git a/app/portainer/services/api/endpointService.js b/app/portainer/services/api/endpointService.js index 9db9f4165..8f1eb5f4d 100644 --- a/app/portainer/services/api/endpointService.js +++ b/app/portainer/services/api/endpointService.js @@ -33,25 +33,12 @@ function EndpointServiceFactory($q, Endpoints, FileUploadService) { return Endpoints.updateAccess({id: id}, {authorizedUsers: authorizedUserIDs, authorizedTeams: authorizedTeamIDs}).$promise; }; - service.updateEndpoint = function(id, endpointParams) { - var query = { - name: endpointParams.name, - PublicURL: endpointParams.PublicURL, - GroupId: endpointParams.GroupId, - TLS: endpointParams.TLS, - TLSSkipVerify: endpointParams.TLSSkipVerify, - TLSSkipClientVerify: endpointParams.TLSSkipClientVerify, - authorizedUsers: endpointParams.authorizedUsers - }; - if (endpointParams.type && endpointParams.URL) { - query.URL = endpointParams.type === 'local' ? ('unix://' + endpointParams.URL) : ('tcp://' + endpointParams.URL); - } - + service.updateEndpoint = function(id, payload) { var deferred = $q.defer(); - FileUploadService.uploadTLSFilesForEndpoint(id, endpointParams.TLSCACert, endpointParams.TLSCert, endpointParams.TLSKey) + FileUploadService.uploadTLSFilesForEndpoint(id, payload.TLSCACert, payload.TLSCert, payload.TLSKey) .then(function success() { deferred.notify({upload: false}); - return Endpoints.update({id: id}, query).$promise; + return Endpoints.update({id: id}, payload).$promise; }) .then(function success(data) { deferred.resolve(data); diff --git a/app/portainer/views/endpoints/edit/endpoint.html b/app/portainer/views/endpoints/edit/endpoint.html index 54e75b445..a8ef8c7a4 100644 --- a/app/portainer/views/endpoints/edit/endpoint.html +++ b/app/portainer/views/endpoints/edit/endpoint.html @@ -28,12 +28,12 @@
- +
-
+
+
Grouping @@ -57,7 +62,7 @@
-
+
Security
diff --git a/app/portainer/views/endpoints/edit/endpointController.js b/app/portainer/views/endpoints/edit/endpointController.js index bdcbc63b0..1d7cd8c3d 100644 --- a/app/portainer/views/endpoints/edit/endpointController.js +++ b/app/portainer/views/endpoints/edit/endpointController.js @@ -23,22 +23,27 @@ function ($q, $scope, $state, $transition$, $filter, EndpointService, GroupServi var TLSSkipVerify = TLS && (TLSMode === 'tls_client_noca' || TLSMode === 'tls_only'); var TLSSkipClientVerify = TLS && (TLSMode === 'tls_ca' || TLSMode === 'tls_only'); - var endpointParams = { - name: endpoint.Name, - URL: endpoint.URL, + var payload = { + Name: endpoint.Name, PublicURL: endpoint.PublicURL, - GroupId: endpoint.GroupId, + GroupID: endpoint.GroupId, TLS: TLS, TLSSkipVerify: TLSSkipVerify, TLSSkipClientVerify: TLSSkipClientVerify, TLSCACert: TLSSkipVerify || securityData.TLSCACert === endpoint.TLSConfig.TLSCACert ? null : securityData.TLSCACert, TLSCert: TLSSkipClientVerify || securityData.TLSCert === endpoint.TLSConfig.TLSCert ? null : securityData.TLSCert, TLSKey: TLSSkipClientVerify || securityData.TLSKey === endpoint.TLSConfig.TLSKey ? null : securityData.TLSKey, - type: $scope.endpointType + AzureApplicationID: endpoint.AzureCredentials.ApplicationID, + AzureTenantID: endpoint.AzureCredentials.TenantID, + AzureAuthenticationKey: endpoint.AzureCredentials.AuthenticationKey }; + if ($scope.endpointType !== 'local' && endpoint.Type !== 3) { + payload.URL = 'tcp://' + endpoint.URL; + } + $scope.state.actionInProgress = true; - EndpointService.updateEndpoint(endpoint.Id, endpointParams) + EndpointService.updateEndpoint(endpoint.Id, payload) .then(function success(data) { Notifications.success('Endpoint updated', $scope.endpoint.Name); EndpointProvider.setEndpointPublicURL(endpoint.PublicURL); From 4429c6a160b8869e5e230b83956be788e48eb7e6 Mon Sep 17 00:00:00 2001 From: Konstantin Azizov Date: Sat, 2 Jun 2018 08:44:18 +0200 Subject: [PATCH 11/37] fix(container-details): recreate container with multiple networks (#1907) * fix(container): Use first network's Mac address by default * fix(container): Connect additional networks to container after creation * fix(container): Remove warning message --- .../create/createContainerController.js | 27 ++++++++++++++++--- .../containers/create/createcontainer.html | 5 ---- 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/app/docker/views/containers/create/createContainerController.js b/app/docker/views/containers/create/createContainerController.js index cca719cf0..d45b989be 100644 --- a/app/docker/views/containers/create/createContainerController.js +++ b/app/docker/views/containers/create/createContainerController.js @@ -19,6 +19,8 @@ function ($q, $scope, $state, $timeout, $transition$, $filter, Container, Contai NodeName: null }; + $scope.extraNetworks = {}; + $scope.state = { formValidationError: '', actionInProgress: false @@ -317,7 +319,7 @@ function ($q, $scope, $state, $timeout, $transition$, $filter, Container, Contai var bindings = []; for (var p in $scope.config.HostConfig.PortBindings) { if ({}.hasOwnProperty.call($scope.config.HostConfig.PortBindings, p)) { - var hostPort = ''; + var hostPort = ''; if ($scope.config.HostConfig.PortBindings[p][0].HostIp) { hostPort = $scope.config.HostConfig.PortBindings[p][0].HostIp + ':'; } @@ -387,7 +389,16 @@ function ($q, $scope, $state, $timeout, $transition$, $filter, Container, Contai } $scope.config.NetworkingConfig.EndpointsConfig[$scope.config.HostConfig.NetworkMode] = d.NetworkSettings.Networks[$scope.config.HostConfig.NetworkMode]; // Mac Address - $scope.formValues.MacAddress = d.NetworkSettings.Networks[$scope.config.HostConfig.NetworkMode].MacAddress; + if(Object.keys(d.NetworkSettings.Networks).length) { + var firstNetwork = d.NetworkSettings.Networks[Object.keys(d.NetworkSettings.Networks)[0]]; + $scope.formValues.MacAddress = firstNetwork.MacAddress; + $scope.config.NetworkingConfig.EndpointsConfig[$scope.config.HostConfig.NetworkMode] = firstNetwork; + $scope.extraNetworks = angular.copy(d.NetworkSettings.Networks); + delete $scope.extraNetworks[Object.keys(d.NetworkSettings.Networks)[0]]; + } else { + $scope.formValues.MacAddress = ''; + } + // ExtraHosts if ($scope.config.HostConfig.ExtraHosts) { var extraHosts = $scope.config.HostConfig.ExtraHosts; @@ -604,14 +615,24 @@ function ($q, $scope, $state, $timeout, $transition$, $filter, Container, Contai }; function createContainer(config, accessControlData) { + var containerIdentifier; $q.when(!$scope.formValues.alwaysPull || ImageService.pullImage($scope.config.Image, $scope.formValues.Registry, true)) .finally(function final() { ContainerService.createAndStartContainer(config) .then(function success(data) { - var containerIdentifier = data.Id; + containerIdentifier = data.Id; var userId = Authentication.getUserDetails().ID; return ResourceControlService.applyResourceControl('container', containerIdentifier, userId, accessControlData, []); }) + .then(function success() { + if($scope.extraNetworks) { + return $q.all( + Object.keys($scope.extraNetworks).map(function(networkName) { + return NetworkService.connectContainer(networkName, containerIdentifier); + }) + ); + } + }) .then(function success() { Notifications.success('Container successfully created'); $state.go('docker.containers', {}, {reload: true}); diff --git a/app/docker/views/containers/create/createcontainer.html b/app/docker/views/containers/create/createcontainer.html index fefcfa7b7..9ef898d8a 100644 --- a/app/docker/views/containers/create/createcontainer.html +++ b/app/docker/views/containers/create/createcontainer.html @@ -126,11 +126,6 @@ Deploy the container Deployment in progress... - {{ state.formValidationError }} - - - This container is connected to multiple networks, only one network will be kept at creation time. -
From 3ace184069f38035eb6df159af245e78f500c6d9 Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Mon, 4 Jun 2018 10:30:53 +0200 Subject: [PATCH 12/37] feat(dashboard): update dashboard info (#1944) --- app/docker/views/dashboard/dashboard.html | 48 +++++++++++++---------- 1 file changed, 28 insertions(+), 20 deletions(-) diff --git a/app/docker/views/dashboard/dashboard.html b/app/docker/views/dashboard/dashboard.html index 6f70f696d..93789d4b1 100644 --- a/app/docker/views/dashboard/dashboard.html +++ b/app/docker/views/dashboard/dashboard.html @@ -10,6 +10,32 @@
+
+ + +
+ Information +
+
+ +

+ + Portainer is connected to a node that is part of a Swarm cluster. Some resources located on other nodes in the cluster might not be available for management, have a look + at our agent setup for more details. +

+

+ + Portainer is connected to a worker node. Swarm management features will not be available. +

+
+
+
+ +
+
+
+
+
@@ -32,29 +58,11 @@ Memory {{ infoData.MemTotal|humansize }} - - - - -
-
- - - - - - - - - + - - - - - +
This node is part of a Swarm cluster
Node role {{ infoData.Swarm.ControlAvailable ? 'Manager' : 'Worker' }}
Nodes in the cluster{{ infoData.Swarm.Nodes }}
Go to cluster visualizer From ef15cd30eb778fa4e73b1ccd087e8d82a624ce49 Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Wed, 6 Jun 2018 18:12:35 +0200 Subject: [PATCH 13/37] style(app): update widget title property (#1952) * style(app): update widget title property * style(containerinstances): fix invalid component title --- .../containerGroupsDatatable.html | 2 +- .../containerinstances/containerinstances.html | 4 ++-- .../create/createcontainerinstance.html | 2 +- app/azure/views/dashboard/dashboard.html | 2 +- .../dashboardClusterAgentInfo.html | 2 +- .../configs-datatable/configsDatatable.html | 2 +- .../configs-datatable/configsDatatable.js | 2 +- .../containerNetworksDatatable.html | 2 +- .../containerNetworksDatatable.js | 2 +- .../containerProcessesDatatable.html | 2 +- .../containerProcessesDatatable.js | 2 +- .../containersDatatable.html | 2 +- .../containers-datatable/containersDatatable.js | 2 +- .../events-datatable/eventsDatatable.html | 2 +- .../events-datatable/eventsDatatable.js | 2 +- .../images-datatable/imagesDatatable.html | 2 +- .../images-datatable/imagesDatatable.js | 2 +- .../networks-datatable/networksDatatable.html | 2 +- .../networks-datatable/networksDatatable.js | 2 +- .../node-tasks-datatable/nodeTasksDatatable.html | 2 +- .../node-tasks-datatable/nodeTasksDatatable.js | 2 +- .../nodes-datatable/nodesDatatable.html | 2 +- .../datatables/nodes-datatable/nodesDatatable.js | 2 +- .../secrets-datatable/secretsDatatable.html | 2 +- .../secrets-datatable/secretsDatatable.js | 2 +- .../services-datatable/servicesDatatable.html | 2 +- .../services-datatable/servicesDatatable.js | 2 +- .../tasks-datatable/tasksDatatable.html | 2 +- .../datatables/tasks-datatable/tasksDatatable.js | 2 +- .../volumes-datatable/volumesDatatable.html | 2 +- .../volumes-datatable/volumesDatatable.js | 2 +- app/docker/components/log-viewer/logViewer.html | 2 +- app/docker/views/configs/configs.html | 4 ++-- .../views/configs/create/createconfig.html | 2 +- app/docker/views/configs/edit/config.html | 6 +++--- .../containers/console/containerconsole.html | 4 ++-- app/docker/views/containers/containers.html | 4 ++-- .../views/containers/create/createcontainer.html | 4 ++-- app/docker/views/containers/edit/container.html | 16 ++++++++-------- .../containers/inspect/containerinspect.html | 4 ++-- .../views/containers/logs/containerlogs.html | 2 +- .../views/containers/stats/containerstats.html | 12 ++++++------ app/docker/views/dashboard/dashboard.html | 4 ++-- app/docker/views/engine/engine.html | 8 ++++---- app/docker/views/events/events.html | 4 ++-- app/docker/views/images/build/buildimage.html | 2 +- app/docker/views/images/edit/image.html | 12 ++++++------ app/docker/views/images/images.html | 6 +++--- .../views/networks/create/createnetwork.html | 2 +- app/docker/views/networks/edit/network.html | 8 ++++---- app/docker/views/networks/networks.html | 4 ++-- app/docker/views/nodes/edit/node.html | 16 ++++++++-------- .../views/secrets/create/createsecret.html | 2 +- app/docker/views/secrets/edit/secret.html | 4 ++-- app/docker/views/secrets/secrets.html | 4 ++-- .../views/services/create/createservice.html | 2 +- .../views/services/edit/includes/configs.html | 2 +- .../services/edit/includes/constraints.html | 2 +- .../services/edit/includes/container-specs.html | 2 +- .../services/edit/includes/containerlabels.html | 2 +- .../edit/includes/environmentvariables.html | 2 +- .../views/services/edit/includes/hosts.html | 2 +- .../views/services/edit/includes/logging.html | 2 +- .../views/services/edit/includes/mounts.html | 2 +- .../views/services/edit/includes/networks.html | 2 +- .../edit/includes/placementPreferences.html | 2 +- .../views/services/edit/includes/ports.html | 2 +- .../views/services/edit/includes/resources.html | 2 +- .../views/services/edit/includes/restart.html | 2 +- .../views/services/edit/includes/secrets.html | 2 +- .../services/edit/includes/servicelabels.html | 2 +- .../views/services/edit/includes/tasks.html | 2 +- .../services/edit/includes/updateconfig.html | 2 +- app/docker/views/services/edit/service.html | 6 +++--- app/docker/views/services/logs/servicelogs.html | 2 +- app/docker/views/services/services.html | 4 ++-- app/docker/views/stacks/create/createstack.html | 2 +- app/docker/views/stacks/edit/stack.html | 8 ++++---- app/docker/views/stacks/stacks.html | 6 +++--- app/docker/views/swarm/swarm.html | 6 +++--- .../views/swarm/visualizer/swarmvisualizer.html | 6 +++--- app/docker/views/tasks/edit/task.html | 4 ++-- app/docker/views/tasks/logs/tasklogs.html | 2 +- app/docker/views/templates/templates.html | 8 ++++---- .../views/volumes/create/createvolume.html | 2 +- app/docker/views/volumes/edit/volume.html | 8 ++++---- app/docker/views/volumes/volumes.html | 4 ++-- .../storidgeClusterEventsDatatable.html | 2 +- .../nodes-datatable/storidgeNodesDatatable.html | 2 +- .../storidgeProfilesDatatable.html | 2 +- .../storidge/views/cluster/cluster.html | 8 ++++---- .../storidge/views/monitor/monitor.html | 12 ++++++------ .../views/profiles/create/createprofile.html | 2 +- .../storidge/views/profiles/edit/profile.html | 2 +- .../storidge/views/profiles/profiles.html | 8 ++++---- .../porAccessControlPanel.html | 2 +- .../accessManagement/porAccessManagement.html | 2 +- .../endpoints-datatable/endpointsDatatable.html | 2 +- .../endpoints-datatable/endpointsDatatable.js | 2 +- .../groups-datatable/groupsDatatable.html | 2 +- .../groups-datatable/groupsDatatable.js | 2 +- .../registriesDatatable.html | 2 +- .../registries-datatable/registriesDatatable.js | 2 +- .../stackServicesDatatable.html | 2 +- .../stackServicesDatatable.js | 2 +- .../stacks-datatable/stacksDatatable.html | 2 +- .../stacks-datatable/stacksDatatable.js | 2 +- .../teams-datatable/teamsDatatable.html | 2 +- .../datatables/teams-datatable/teamsDatatable.js | 2 +- .../users-datatable/usersDatatable.html | 2 +- .../datatables/users-datatable/usersDatatable.js | 2 +- app/portainer/components/header-title.js | 4 ++-- app/portainer/components/widget-custom-header.js | 4 ++-- app/portainer/components/widget-header.js | 4 ++-- app/portainer/views/about/about.html | 2 +- app/portainer/views/account/account.html | 4 ++-- .../views/endpoints/access/endpointAccess.html | 4 ++-- .../views/endpoints/create/createendpoint.html | 2 +- app/portainer/views/endpoints/edit/endpoint.html | 2 +- app/portainer/views/endpoints/endpoints.html | 6 +++--- .../views/groups/access/groupAccess.html | 4 ++-- .../views/groups/create/creategroup.html | 2 +- app/portainer/views/groups/edit/group.html | 2 +- app/portainer/views/groups/groups.html | 4 ++-- .../views/registries/access/registryAccess.html | 4 ++-- .../views/registries/create/createregistry.html | 2 +- .../views/registries/edit/registry.html | 2 +- app/portainer/views/registries/registries.html | 6 +++--- .../authentication/settingsAuthentication.html | 4 ++-- app/portainer/views/settings/settings.html | 6 +++--- app/portainer/views/support/support.html | 2 +- app/portainer/views/teams/edit/team.html | 8 ++++---- app/portainer/views/teams/teams.html | 6 +++--- app/portainer/views/users/edit/user.html | 6 +++--- app/portainer/views/users/users.html | 6 +++--- 135 files changed, 235 insertions(+), 235 deletions(-) diff --git a/app/azure/components/datatables/containergroups-datatable/containerGroupsDatatable.html b/app/azure/components/datatables/containergroups-datatable/containerGroupsDatatable.html index c87f74735..f1113aadb 100644 --- a/app/azure/components/datatables/containergroups-datatable/containerGroupsDatatable.html +++ b/app/azure/components/datatables/containergroups-datatable/containerGroupsDatatable.html @@ -3,7 +3,7 @@
- {{ $ctrl.title }} + {{ $ctrl.titleText }}
diff --git a/app/azure/views/containerinstances/containerinstances.html b/app/azure/views/containerinstances/containerinstances.html index 489d6b807..af3990f8e 100644 --- a/app/azure/views/containerinstances/containerinstances.html +++ b/app/azure/views/containerinstances/containerinstances.html @@ -1,5 +1,5 @@ - + @@ -10,7 +10,7 @@
- + Container instances > Add container diff --git a/app/azure/views/dashboard/dashboard.html b/app/azure/views/dashboard/dashboard.html index 5726304c1..eaa60a53e 100644 --- a/app/azure/views/dashboard/dashboard.html +++ b/app/azure/views/dashboard/dashboard.html @@ -1,5 +1,5 @@ - + Dashboard diff --git a/app/docker/components/dashboard-cluster-agent-info/dashboardClusterAgentInfo.html b/app/docker/components/dashboard-cluster-agent-info/dashboardClusterAgentInfo.html index 7012bb15c..8d8cafc34 100644 --- a/app/docker/components/dashboard-cluster-agent-info/dashboardClusterAgentInfo.html +++ b/app/docker/components/dashboard-cluster-agent-info/dashboardClusterAgentInfo.html @@ -1,5 +1,5 @@ - + diff --git a/app/docker/components/datatables/configs-datatable/configsDatatable.html b/app/docker/components/datatables/configs-datatable/configsDatatable.html index f41830f44..76b5ec683 100644 --- a/app/docker/components/datatables/configs-datatable/configsDatatable.html +++ b/app/docker/components/datatables/configs-datatable/configsDatatable.html @@ -3,7 +3,7 @@
- {{ $ctrl.title }} + {{ $ctrl.titleText }}
diff --git a/app/docker/components/datatables/configs-datatable/configsDatatable.js b/app/docker/components/datatables/configs-datatable/configsDatatable.js index 66f774419..5b31c965e 100644 --- a/app/docker/components/datatables/configs-datatable/configsDatatable.js +++ b/app/docker/components/datatables/configs-datatable/configsDatatable.js @@ -2,7 +2,7 @@ angular.module('portainer.docker').component('configsDatatable', { templateUrl: 'app/docker/components/datatables/configs-datatable/configsDatatable.html', controller: 'GenericDatatableController', bindings: { - title: '@', + titleText: '@', titleIcon: '@', dataset: '<', tableKey: '@', diff --git a/app/docker/components/datatables/container-networks-datatable/containerNetworksDatatable.html b/app/docker/components/datatables/container-networks-datatable/containerNetworksDatatable.html index 5eddcae40..0b4ba9454 100644 --- a/app/docker/components/datatables/container-networks-datatable/containerNetworksDatatable.html +++ b/app/docker/components/datatables/container-networks-datatable/containerNetworksDatatable.html @@ -3,7 +3,7 @@
- {{ $ctrl.title }} + {{ $ctrl.titleText }}
diff --git a/app/docker/components/datatables/container-networks-datatable/containerNetworksDatatable.js b/app/docker/components/datatables/container-networks-datatable/containerNetworksDatatable.js index 2b294141b..d807b7019 100644 --- a/app/docker/components/datatables/container-networks-datatable/containerNetworksDatatable.js +++ b/app/docker/components/datatables/container-networks-datatable/containerNetworksDatatable.js @@ -2,7 +2,7 @@ angular.module('portainer.docker').component('containerNetworksDatatable', { templateUrl: 'app/docker/components/datatables/container-networks-datatable/containerNetworksDatatable.html', controller: 'GenericDatatableController', bindings: { - title: '@', + titleText: '@', titleIcon: '@', dataset: '<', tableKey: '@', diff --git a/app/docker/components/datatables/container-processes-datatable/containerProcessesDatatable.html b/app/docker/components/datatables/container-processes-datatable/containerProcessesDatatable.html index 004f9721f..3284f3e22 100644 --- a/app/docker/components/datatables/container-processes-datatable/containerProcessesDatatable.html +++ b/app/docker/components/datatables/container-processes-datatable/containerProcessesDatatable.html @@ -3,7 +3,7 @@
- {{ $ctrl.title }} + {{ $ctrl.titleText }}
diff --git a/app/docker/components/datatables/container-processes-datatable/containerProcessesDatatable.js b/app/docker/components/datatables/container-processes-datatable/containerProcessesDatatable.js index 51afee8be..621ff7bf3 100644 --- a/app/docker/components/datatables/container-processes-datatable/containerProcessesDatatable.js +++ b/app/docker/components/datatables/container-processes-datatable/containerProcessesDatatable.js @@ -2,7 +2,7 @@ angular.module('portainer.docker').component('containerProcessesDatatable', { templateUrl: 'app/docker/components/datatables/container-processes-datatable/containerProcessesDatatable.html', controller: 'GenericDatatableController', bindings: { - title: '@', + titleText: '@', titleIcon: '@', dataset: '=', headerset: '<', diff --git a/app/docker/components/datatables/containers-datatable/containersDatatable.html b/app/docker/components/datatables/containers-datatable/containersDatatable.html index 9ce58d5f7..e07e89dbd 100644 --- a/app/docker/components/datatables/containers-datatable/containersDatatable.html +++ b/app/docker/components/datatables/containers-datatable/containersDatatable.html @@ -3,7 +3,7 @@
- {{ $ctrl.title }} + {{ $ctrl.titleText }}
diff --git a/app/docker/components/datatables/containers-datatable/containersDatatable.js b/app/docker/components/datatables/containers-datatable/containersDatatable.js index 4e806b415..8d04ef011 100644 --- a/app/docker/components/datatables/containers-datatable/containersDatatable.js +++ b/app/docker/components/datatables/containers-datatable/containersDatatable.js @@ -2,7 +2,7 @@ angular.module('portainer.docker').component('containersDatatable', { templateUrl: 'app/docker/components/datatables/containers-datatable/containersDatatable.html', controller: 'ContainersDatatableController', bindings: { - title: '@', + titleText: '@', titleIcon: '@', dataset: '<', tableKey: '@', diff --git a/app/docker/components/datatables/events-datatable/eventsDatatable.html b/app/docker/components/datatables/events-datatable/eventsDatatable.html index b9554be4c..90625950b 100644 --- a/app/docker/components/datatables/events-datatable/eventsDatatable.html +++ b/app/docker/components/datatables/events-datatable/eventsDatatable.html @@ -3,7 +3,7 @@
- {{ $ctrl.title }} + {{ $ctrl.titleText }}
diff --git a/app/docker/components/datatables/events-datatable/eventsDatatable.js b/app/docker/components/datatables/events-datatable/eventsDatatable.js index 9db4141fe..d81d10314 100644 --- a/app/docker/components/datatables/events-datatable/eventsDatatable.js +++ b/app/docker/components/datatables/events-datatable/eventsDatatable.js @@ -2,7 +2,7 @@ angular.module('portainer.docker').component('eventsDatatable', { templateUrl: 'app/docker/components/datatables/events-datatable/eventsDatatable.html', controller: 'GenericDatatableController', bindings: { - title: '@', + titleText: '@', titleIcon: '@', dataset: '<', tableKey: '@', diff --git a/app/docker/components/datatables/images-datatable/imagesDatatable.html b/app/docker/components/datatables/images-datatable/imagesDatatable.html index bded973b1..6816f375b 100644 --- a/app/docker/components/datatables/images-datatable/imagesDatatable.html +++ b/app/docker/components/datatables/images-datatable/imagesDatatable.html @@ -3,7 +3,7 @@
- {{ $ctrl.title }} + {{ $ctrl.titleText }}
diff --git a/app/docker/components/datatables/images-datatable/imagesDatatable.js b/app/docker/components/datatables/images-datatable/imagesDatatable.js index d2e5305d4..e3d0477a5 100644 --- a/app/docker/components/datatables/images-datatable/imagesDatatable.js +++ b/app/docker/components/datatables/images-datatable/imagesDatatable.js @@ -2,7 +2,7 @@ angular.module('portainer.docker').component('imagesDatatable', { templateUrl: 'app/docker/components/datatables/images-datatable/imagesDatatable.html', controller: 'ImagesDatatableController', bindings: { - title: '@', + titleText: '@', titleIcon: '@', dataset: '<', tableKey: '@', diff --git a/app/docker/components/datatables/networks-datatable/networksDatatable.html b/app/docker/components/datatables/networks-datatable/networksDatatable.html index 6ccf496f6..eee23d6d4 100644 --- a/app/docker/components/datatables/networks-datatable/networksDatatable.html +++ b/app/docker/components/datatables/networks-datatable/networksDatatable.html @@ -3,7 +3,7 @@
- {{ $ctrl.title }} + {{ $ctrl.titleText }}
diff --git a/app/docker/components/datatables/networks-datatable/networksDatatable.js b/app/docker/components/datatables/networks-datatable/networksDatatable.js index 1f7527890..5bd04f1aa 100644 --- a/app/docker/components/datatables/networks-datatable/networksDatatable.js +++ b/app/docker/components/datatables/networks-datatable/networksDatatable.js @@ -2,7 +2,7 @@ angular.module('portainer.docker').component('networksDatatable', { templateUrl: 'app/docker/components/datatables/networks-datatable/networksDatatable.html', controller: 'GenericDatatableController', bindings: { - title: '@', + titleText: '@', titleIcon: '@', dataset: '<', tableKey: '@', diff --git a/app/docker/components/datatables/node-tasks-datatable/nodeTasksDatatable.html b/app/docker/components/datatables/node-tasks-datatable/nodeTasksDatatable.html index 22d3d7cbf..f5d6aa285 100644 --- a/app/docker/components/datatables/node-tasks-datatable/nodeTasksDatatable.html +++ b/app/docker/components/datatables/node-tasks-datatable/nodeTasksDatatable.html @@ -3,7 +3,7 @@
- {{ $ctrl.title }} + {{ $ctrl.titleText }}
diff --git a/app/docker/components/datatables/node-tasks-datatable/nodeTasksDatatable.js b/app/docker/components/datatables/node-tasks-datatable/nodeTasksDatatable.js index 9a022baa5..6df455b9c 100644 --- a/app/docker/components/datatables/node-tasks-datatable/nodeTasksDatatable.js +++ b/app/docker/components/datatables/node-tasks-datatable/nodeTasksDatatable.js @@ -2,7 +2,7 @@ angular.module('portainer.docker').component('nodeTasksDatatable', { templateUrl: 'app/docker/components/datatables/node-tasks-datatable/nodeTasksDatatable.html', controller: 'GenericDatatableController', bindings: { - title: '@', + titleText: '@', titleIcon: '@', dataset: '<', tableKey: '@', diff --git a/app/docker/components/datatables/nodes-datatable/nodesDatatable.html b/app/docker/components/datatables/nodes-datatable/nodesDatatable.html index ab825e5fa..1dc48abd1 100644 --- a/app/docker/components/datatables/nodes-datatable/nodesDatatable.html +++ b/app/docker/components/datatables/nodes-datatable/nodesDatatable.html @@ -3,7 +3,7 @@
- {{ $ctrl.title }} + {{ $ctrl.titleText }}
diff --git a/app/docker/components/datatables/nodes-datatable/nodesDatatable.js b/app/docker/components/datatables/nodes-datatable/nodesDatatable.js index dbf8d2d87..d3e42449b 100644 --- a/app/docker/components/datatables/nodes-datatable/nodesDatatable.js +++ b/app/docker/components/datatables/nodes-datatable/nodesDatatable.js @@ -2,7 +2,7 @@ angular.module('portainer.docker').component('nodesDatatable', { templateUrl: 'app/docker/components/datatables/nodes-datatable/nodesDatatable.html', controller: 'GenericDatatableController', bindings: { - title: '@', + titleText: '@', titleIcon: '@', dataset: '<', tableKey: '@', diff --git a/app/docker/components/datatables/secrets-datatable/secretsDatatable.html b/app/docker/components/datatables/secrets-datatable/secretsDatatable.html index 300466505..6aecaf077 100644 --- a/app/docker/components/datatables/secrets-datatable/secretsDatatable.html +++ b/app/docker/components/datatables/secrets-datatable/secretsDatatable.html @@ -3,7 +3,7 @@
- {{ $ctrl.title }} + {{ $ctrl.titleText }}
diff --git a/app/docker/components/datatables/secrets-datatable/secretsDatatable.js b/app/docker/components/datatables/secrets-datatable/secretsDatatable.js index 7c55738a9..e572901f4 100644 --- a/app/docker/components/datatables/secrets-datatable/secretsDatatable.js +++ b/app/docker/components/datatables/secrets-datatable/secretsDatatable.js @@ -2,7 +2,7 @@ angular.module('portainer.docker').component('secretsDatatable', { templateUrl: 'app/docker/components/datatables/secrets-datatable/secretsDatatable.html', controller: 'GenericDatatableController', bindings: { - title: '@', + titleText: '@', titleIcon: '@', dataset: '<', tableKey: '@', diff --git a/app/docker/components/datatables/services-datatable/servicesDatatable.html b/app/docker/components/datatables/services-datatable/servicesDatatable.html index 12794918d..c0bc7720e 100644 --- a/app/docker/components/datatables/services-datatable/servicesDatatable.html +++ b/app/docker/components/datatables/services-datatable/servicesDatatable.html @@ -3,7 +3,7 @@
- {{ $ctrl.title }} + {{ $ctrl.titleText }}
diff --git a/app/docker/components/datatables/services-datatable/servicesDatatable.js b/app/docker/components/datatables/services-datatable/servicesDatatable.js index e87726251..5280d5261 100644 --- a/app/docker/components/datatables/services-datatable/servicesDatatable.js +++ b/app/docker/components/datatables/services-datatable/servicesDatatable.js @@ -2,7 +2,7 @@ angular.module('portainer.docker').component('servicesDatatable', { templateUrl: 'app/docker/components/datatables/services-datatable/servicesDatatable.html', controller: 'GenericDatatableController', bindings: { - title: '@', + titleText: '@', titleIcon: '@', dataset: '<', tableKey: '@', diff --git a/app/docker/components/datatables/tasks-datatable/tasksDatatable.html b/app/docker/components/datatables/tasks-datatable/tasksDatatable.html index abe642d60..287be66b3 100644 --- a/app/docker/components/datatables/tasks-datatable/tasksDatatable.html +++ b/app/docker/components/datatables/tasks-datatable/tasksDatatable.html @@ -3,7 +3,7 @@
- {{ $ctrl.title }} + {{ $ctrl.titleText }}
diff --git a/app/docker/components/datatables/tasks-datatable/tasksDatatable.js b/app/docker/components/datatables/tasks-datatable/tasksDatatable.js index 991c328ec..58e54f134 100644 --- a/app/docker/components/datatables/tasks-datatable/tasksDatatable.js +++ b/app/docker/components/datatables/tasks-datatable/tasksDatatable.js @@ -2,7 +2,7 @@ angular.module('portainer.docker').component('tasksDatatable', { templateUrl: 'app/docker/components/datatables/tasks-datatable/tasksDatatable.html', controller: 'GenericDatatableController', bindings: { - title: '@', + titleText: '@', titleIcon: '@', dataset: '<', tableKey: '@', diff --git a/app/docker/components/datatables/volumes-datatable/volumesDatatable.html b/app/docker/components/datatables/volumes-datatable/volumesDatatable.html index 3d896409c..1a271dfaa 100644 --- a/app/docker/components/datatables/volumes-datatable/volumesDatatable.html +++ b/app/docker/components/datatables/volumes-datatable/volumesDatatable.html @@ -3,7 +3,7 @@
- {{ $ctrl.title }} + {{ $ctrl.titleText }}
diff --git a/app/docker/components/datatables/volumes-datatable/volumesDatatable.js b/app/docker/components/datatables/volumes-datatable/volumesDatatable.js index 8d1707fe5..da8119da9 100644 --- a/app/docker/components/datatables/volumes-datatable/volumesDatatable.js +++ b/app/docker/components/datatables/volumes-datatable/volumesDatatable.js @@ -2,7 +2,7 @@ angular.module('portainer.docker').component('volumesDatatable', { templateUrl: 'app/docker/components/datatables/volumes-datatable/volumesDatatable.html', controller: 'VolumesDatatableController', bindings: { - title: '@', + titleText: '@', titleIcon: '@', dataset: '<', tableKey: '@', diff --git a/app/docker/components/log-viewer/logViewer.html b/app/docker/components/log-viewer/logViewer.html index 6f6140d4f..8abda6ee5 100644 --- a/app/docker/components/log-viewer/logViewer.html +++ b/app/docker/components/log-viewer/logViewer.html @@ -1,7 +1,7 @@
- +
diff --git a/app/docker/views/configs/configs.html b/app/docker/views/configs/configs.html index c8bcedac4..45cc474c5 100644 --- a/app/docker/views/configs/configs.html +++ b/app/docker/views/configs/configs.html @@ -1,5 +1,5 @@ - + @@ -10,7 +10,7 @@
- + Configs > Add config diff --git a/app/docker/views/configs/edit/config.html b/app/docker/views/configs/edit/config.html index c15bbdbbc..376bd2014 100644 --- a/app/docker/views/configs/edit/config.html +++ b/app/docker/views/configs/edit/config.html @@ -1,5 +1,5 @@ - + @@ -12,7 +12,7 @@
- +
@@ -65,7 +65,7 @@
- +
diff --git a/app/docker/views/containers/console/containerconsole.html b/app/docker/views/containers/console/containerconsole.html index 9432c3a6f..f8b4e7e54 100644 --- a/app/docker/views/containers/console/containerconsole.html +++ b/app/docker/views/containers/console/containerconsole.html @@ -1,5 +1,5 @@ - + Containers > {{ container.Name|trimcontainername }} > Console @@ -8,7 +8,7 @@
- +
diff --git a/app/docker/views/containers/containers.html b/app/docker/views/containers/containers.html index f04727b6d..2e831c7ef 100644 --- a/app/docker/views/containers/containers.html +++ b/app/docker/views/containers/containers.html @@ -1,5 +1,5 @@ - + @@ -10,7 +10,7 @@
- + Containers > Add container @@ -138,7 +138,7 @@
- +
@@ -110,7 +110,7 @@
- +
@@ -139,7 +139,7 @@
- + @@ -178,7 +178,7 @@
- +
@@ -247,7 +247,7 @@
- +
@@ -272,7 +272,7 @@
- + Containers > {{ containerInfo.Name|trimcontainername }} > Inspect @@ -9,7 +9,7 @@
- + diff --git a/app/docker/views/containers/logs/containerlogs.html b/app/docker/views/containers/logs/containerlogs.html index a569f203a..804731ec5 100644 --- a/app/docker/views/containers/logs/containerlogs.html +++ b/app/docker/views/containers/logs/containerlogs.html @@ -1,5 +1,5 @@ - + Containers > {{ container.Name|trimcontainername }} > Logs diff --git a/app/docker/views/containers/stats/containerstats.html b/app/docker/views/containers/stats/containerstats.html index f0a974956..99ccf06a4 100644 --- a/app/docker/views/containers/stats/containerstats.html +++ b/app/docker/views/containers/stats/containerstats.html @@ -1,5 +1,5 @@ - + Containers > {{ container.Name|trimcontainername }} > Stats @@ -8,7 +8,7 @@
- + @@ -52,7 +52,7 @@
- +
@@ -62,7 +62,7 @@
- +
@@ -72,7 +72,7 @@
- +
@@ -83,7 +83,7 @@
- + Dashboard @@ -38,7 +38,7 @@
- +
diff --git a/app/docker/views/engine/engine.html b/app/docker/views/engine/engine.html index cecdf8f51..73b1248b2 100644 --- a/app/docker/views/engine/engine.html +++ b/app/docker/views/engine/engine.html @@ -1,5 +1,5 @@ - + @@ -10,7 +10,7 @@
- +
@@ -52,7 +52,7 @@
- +
@@ -94,7 +94,7 @@
- +
diff --git a/app/docker/views/events/events.html b/app/docker/views/events/events.html index 96613262f..87ebb9af8 100644 --- a/app/docker/views/events/events.html +++ b/app/docker/views/events/events.html @@ -1,5 +1,5 @@ - + @@ -10,7 +10,7 @@
- + Images > Build image diff --git a/app/docker/views/images/edit/image.html b/app/docker/views/images/edit/image.html index 1cc50a61b..352784788 100644 --- a/app/docker/views/images/edit/image.html +++ b/app/docker/views/images/edit/image.html @@ -1,5 +1,5 @@ - + Images > {{ image.Id }} @@ -8,7 +8,7 @@
- +
@@ -59,7 +59,7 @@
- + @@ -88,7 +88,7 @@
- +
@@ -129,7 +129,7 @@
- +
@@ -178,7 +178,7 @@
- +
diff --git a/app/docker/views/images/images.html b/app/docker/views/images/images.html index 1b6c807c3..9150a3a05 100644 --- a/app/docker/views/images/images.html +++ b/app/docker/views/images/images.html @@ -1,5 +1,5 @@ - + @@ -10,7 +10,7 @@
- + @@ -53,7 +53,7 @@
- + Networks > Add network diff --git a/app/docker/views/networks/edit/network.html b/app/docker/views/networks/edit/network.html index b9e269187..08db62c3f 100644 --- a/app/docker/views/networks/edit/network.html +++ b/app/docker/views/networks/edit/network.html @@ -1,5 +1,5 @@ - + Networks > {{ network.Name }} @@ -8,7 +8,7 @@
- +
@@ -58,7 +58,7 @@
- +
@@ -77,7 +77,7 @@
- +
diff --git a/app/docker/views/networks/networks.html b/app/docker/views/networks/networks.html index 1c224f0da..9137d518d 100644 --- a/app/docker/views/networks/networks.html +++ b/app/docker/views/networks/networks.html @@ -1,5 +1,5 @@ - + @@ -10,7 +10,7 @@
- + @@ -16,7 +16,7 @@
- +

It looks like the node you wish to inspect does not exist.

@@ -27,7 +27,7 @@
- +
@@ -87,7 +87,7 @@
- +
@@ -116,7 +116,7 @@
- +
@@ -146,7 +146,7 @@
- +

There are no engine labels for this node.

@@ -173,7 +173,7 @@
- +
label @@ -234,7 +234,7 @@
- + Secrets > Add secret diff --git a/app/docker/views/secrets/edit/secret.html b/app/docker/views/secrets/edit/secret.html index 0489137e9..c8c5fa3de 100644 --- a/app/docker/views/secrets/edit/secret.html +++ b/app/docker/views/secrets/edit/secret.html @@ -1,5 +1,5 @@ - + @@ -12,7 +12,7 @@
- +
diff --git a/app/docker/views/secrets/secrets.html b/app/docker/views/secrets/secrets.html index e8d5fc3e0..19f5bfb3c 100644 --- a/app/docker/views/secrets/secrets.html +++ b/app/docker/views/secrets/secrets.html @@ -1,5 +1,5 @@ - + @@ -10,7 +10,7 @@
- + Services > Add service diff --git a/app/docker/views/services/edit/includes/configs.html b/app/docker/views/services/edit/includes/configs.html index 0a8c7cc0e..db36d0edb 100644 --- a/app/docker/views/services/edit/includes/configs.html +++ b/app/docker/views/services/edit/includes/configs.html @@ -1,6 +1,6 @@ - +
diff --git a/app/docker/views/services/edit/includes/constraints.html b/app/docker/views/services/edit/includes/constraints.html index 3817f939d..496ea1492 100644 --- a/app/docker/views/services/edit/includes/constraints.html +++ b/app/docker/views/services/edit/includes/constraints.html @@ -1,6 +1,6 @@
diff --git a/app/docker/views/services/edit/includes/containerlabels.html b/app/docker/views/services/edit/includes/containerlabels.html index 78607b23d..5e8290a27 100644 --- a/app/docker/views/services/edit/includes/containerlabels.html +++ b/app/docker/views/services/edit/includes/containerlabels.html @@ -1,6 +1,6 @@
- +
container label diff --git a/app/docker/views/services/edit/includes/environmentvariables.html b/app/docker/views/services/edit/includes/environmentvariables.html index 4b594a8fb..a1a5d415e 100644 --- a/app/docker/views/services/edit/includes/environmentvariables.html +++ b/app/docker/views/services/edit/includes/environmentvariables.html @@ -1,6 +1,6 @@
- +
environment variable diff --git a/app/docker/views/services/edit/includes/hosts.html b/app/docker/views/services/edit/includes/hosts.html index 7d9ab78c7..a425f2282 100644 --- a/app/docker/views/services/edit/includes/hosts.html +++ b/app/docker/views/services/edit/includes/hosts.html @@ -1,6 +1,6 @@
- +
add host entry diff --git a/app/docker/views/services/edit/includes/logging.html b/app/docker/views/services/edit/includes/logging.html index 2172ac902..b7eb2b7e8 100644 --- a/app/docker/views/services/edit/includes/logging.html +++ b/app/docker/views/services/edit/includes/logging.html @@ -1,6 +1,6 @@
- +
diff --git a/app/docker/views/services/edit/includes/mounts.html b/app/docker/views/services/edit/includes/mounts.html index e8a7b181c..9a88e86e7 100644 --- a/app/docker/views/services/edit/includes/mounts.html +++ b/app/docker/views/services/edit/includes/mounts.html @@ -1,6 +1,6 @@
diff --git a/app/docker/views/services/edit/includes/restart.html b/app/docker/views/services/edit/includes/restart.html index e42de0173..450791c7d 100644 --- a/app/docker/views/services/edit/includes/restart.html +++ b/app/docker/views/services/edit/includes/restart.html @@ -1,6 +1,6 @@
- +
diff --git a/app/docker/views/services/edit/includes/secrets.html b/app/docker/views/services/edit/includes/secrets.html index 57b21aa3c..04444aa6d 100644 --- a/app/docker/views/services/edit/includes/secrets.html +++ b/app/docker/views/services/edit/includes/secrets.html @@ -1,6 +1,6 @@
diff --git a/app/docker/views/services/edit/service.html b/app/docker/views/services/edit/service.html index 6669f53a4..e0b2b49de 100644 --- a/app/docker/views/services/edit/service.html +++ b/app/docker/views/services/edit/service.html @@ -1,5 +1,5 @@ - + @@ -21,7 +21,7 @@
- +
@@ -109,7 +109,7 @@
- +
@@ -47,7 +47,7 @@
- + @@ -12,7 +12,7 @@
- +
@@ -77,7 +77,7 @@
- +
diff --git a/app/docker/views/tasks/edit/task.html b/app/docker/views/tasks/edit/task.html index e61bc62dd..7bef3c52f 100644 --- a/app/docker/views/tasks/edit/task.html +++ b/app/docker/views/tasks/edit/task.html @@ -1,5 +1,5 @@ - + Services > {{ service.Name }} > {{ task.Id }} @@ -8,7 +8,7 @@
- +
diff --git a/app/docker/views/tasks/logs/tasklogs.html b/app/docker/views/tasks/logs/tasklogs.html index 6c5a15f41..abfaa6d45 100644 --- a/app/docker/views/tasks/logs/tasklogs.html +++ b/app/docker/views/tasks/logs/tasklogs.html @@ -1,5 +1,5 @@ - + Services > {{ service.Name }} > {{ task.Id }} > Logs diff --git a/app/docker/views/templates/templates.html b/app/docker/views/templates/templates.html index 5dcec4d26..3c029d719 100644 --- a/app/docker/views/templates/templates.html +++ b/app/docker/views/templates/templates.html @@ -1,5 +1,5 @@ - + @@ -11,7 +11,7 @@
- +
@@ -81,7 +81,7 @@
- +
@@ -350,7 +350,7 @@
- +
Category
@@ -57,7 +57,7 @@
- +
@@ -74,7 +74,7 @@
- +
diff --git a/app/docker/views/volumes/volumes.html b/app/docker/views/volumes/volumes.html index b31614b21..309dd6686 100644 --- a/app/docker/views/volumes/volumes.html +++ b/app/docker/views/volumes/volumes.html @@ -1,5 +1,5 @@ - + @@ -10,7 +10,7 @@
- {{ $ctrl.title }} + {{ $ctrl.titleText }}
diff --git a/app/extensions/storidge/components/nodes-datatable/storidgeNodesDatatable.html b/app/extensions/storidge/components/nodes-datatable/storidgeNodesDatatable.html index 973b517d7..ca9fd327b 100644 --- a/app/extensions/storidge/components/nodes-datatable/storidgeNodesDatatable.html +++ b/app/extensions/storidge/components/nodes-datatable/storidgeNodesDatatable.html @@ -3,7 +3,7 @@
- {{ $ctrl.title }} + {{ $ctrl.titleText }}
diff --git a/app/extensions/storidge/components/profiles-datatable/storidgeProfilesDatatable.html b/app/extensions/storidge/components/profiles-datatable/storidgeProfilesDatatable.html index 642f69a03..24497f375 100644 --- a/app/extensions/storidge/components/profiles-datatable/storidgeProfilesDatatable.html +++ b/app/extensions/storidge/components/profiles-datatable/storidgeProfilesDatatable.html @@ -3,7 +3,7 @@
- {{ $ctrl.title }} + {{ $ctrl.titleText }}
diff --git a/app/extensions/storidge/views/cluster/cluster.html b/app/extensions/storidge/views/cluster/cluster.html index 9f40e7035..86bfb95af 100644 --- a/app/extensions/storidge/views/cluster/cluster.html +++ b/app/extensions/storidge/views/cluster/cluster.html @@ -1,5 +1,5 @@ - + @@ -12,7 +12,7 @@
- +
@@ -55,7 +55,7 @@
@@ -65,7 +65,7 @@ diff --git a/app/portainer/views/endpoints/access/endpointAccess.html b/app/portainer/views/endpoints/access/endpointAccess.html index eae43d390..ed60af804 100644 --- a/app/portainer/views/endpoints/access/endpointAccess.html +++ b/app/portainer/views/endpoints/access/endpointAccess.html @@ -1,5 +1,5 @@ - + Endpoints > {{ endpoint.Name }} > Access management @@ -8,7 +8,7 @@
- +
diff --git a/app/portainer/views/endpoints/create/createendpoint.html b/app/portainer/views/endpoints/create/createendpoint.html index 3afc1f55e..a4053d8f1 100644 --- a/app/portainer/views/endpoints/create/createendpoint.html +++ b/app/portainer/views/endpoints/create/createendpoint.html @@ -1,5 +1,5 @@ - + Endpoints > Add endpoint diff --git a/app/portainer/views/endpoints/edit/endpoint.html b/app/portainer/views/endpoints/edit/endpoint.html index a8ef8c7a4..c225d34fa 100644 --- a/app/portainer/views/endpoints/edit/endpoint.html +++ b/app/portainer/views/endpoints/edit/endpoint.html @@ -1,5 +1,5 @@ - + Endpoints > {{ endpoint.Name }} diff --git a/app/portainer/views/endpoints/endpoints.html b/app/portainer/views/endpoints/endpoints.html index 75459a89a..cb37ae53e 100644 --- a/app/portainer/views/endpoints/endpoints.html +++ b/app/portainer/views/endpoints/endpoints.html @@ -1,5 +1,5 @@ - + @@ -10,7 +10,7 @@
- + Portainer has been started using the --external-endpoints flag. @@ -25,7 +25,7 @@
- + Groups > {{ group.Name }} > Access management @@ -8,7 +8,7 @@
- +
diff --git a/app/portainer/views/groups/create/creategroup.html b/app/portainer/views/groups/create/creategroup.html index e4938e6ff..8d307d40a 100644 --- a/app/portainer/views/groups/create/creategroup.html +++ b/app/portainer/views/groups/create/creategroup.html @@ -1,5 +1,5 @@ - + Endpoint groups > Add group diff --git a/app/portainer/views/groups/edit/group.html b/app/portainer/views/groups/edit/group.html index 4d5861d80..1ea4c7ab5 100644 --- a/app/portainer/views/groups/edit/group.html +++ b/app/portainer/views/groups/edit/group.html @@ -1,5 +1,5 @@ - + Groups > {{ group.Name }} diff --git a/app/portainer/views/groups/groups.html b/app/portainer/views/groups/groups.html index 2e6f91c39..ccf33790b 100644 --- a/app/portainer/views/groups/groups.html +++ b/app/portainer/views/groups/groups.html @@ -1,5 +1,5 @@ - + @@ -10,7 +10,7 @@
- + Registries > {{ registry.Name }} > Access management @@ -8,7 +8,7 @@
- +
diff --git a/app/portainer/views/registries/create/createregistry.html b/app/portainer/views/registries/create/createregistry.html index 632a09d12..e4e1499b0 100644 --- a/app/portainer/views/registries/create/createregistry.html +++ b/app/portainer/views/registries/create/createregistry.html @@ -1,5 +1,5 @@ - + Registries > Add registry diff --git a/app/portainer/views/registries/edit/registry.html b/app/portainer/views/registries/edit/registry.html index 9e794548c..c4d8ba9df 100644 --- a/app/portainer/views/registries/edit/registry.html +++ b/app/portainer/views/registries/edit/registry.html @@ -1,5 +1,5 @@ - + Registries > {{ registry.Name }} diff --git a/app/portainer/views/registries/registries.html b/app/portainer/views/registries/registries.html index 8c822d8ee..72a7561d3 100644 --- a/app/portainer/views/registries/registries.html +++ b/app/portainer/views/registries/registries.html @@ -1,5 +1,5 @@ - + @@ -10,7 +10,7 @@
- + @@ -71,7 +71,7 @@
- + Settings > Authentication @@ -8,7 +8,7 @@
- +
diff --git a/app/portainer/views/settings/settings.html b/app/portainer/views/settings/settings.html index dc9671746..83195f3e7 100644 --- a/app/portainer/views/settings/settings.html +++ b/app/portainer/views/settings/settings.html @@ -1,12 +1,12 @@ - + Settings
- + @@ -123,7 +123,7 @@
- +
diff --git a/app/portainer/views/support/support.html b/app/portainer/views/support/support.html index cbedc28cb..fd76f5297 100644 --- a/app/portainer/views/support/support.html +++ b/app/portainer/views/support/support.html @@ -1,5 +1,5 @@ - + Portainer support diff --git a/app/portainer/views/teams/edit/team.html b/app/portainer/views/teams/edit/team.html index f0b4ca3be..dcd1eb1f8 100644 --- a/app/portainer/views/teams/edit/team.html +++ b/app/portainer/views/teams/edit/team.html @@ -1,5 +1,5 @@ - + Teams > {{ team.Name }} @@ -8,7 +8,7 @@
- +
@@ -37,7 +37,7 @@
- +
Items per page: diff --git a/app/portainer/views/teams/teams.html b/app/portainer/views/teams/teams.html index 66c4b6e54..ab1088faa 100644 --- a/app/portainer/views/teams/teams.html +++ b/app/portainer/views/teams/teams.html @@ -1,5 +1,5 @@ - + @@ -10,7 +10,7 @@
- + @@ -67,7 +67,7 @@
- + Users > {{ user.Username }} @@ -8,7 +8,7 @@
- +
@@ -40,7 +40,7 @@
- + diff --git a/app/portainer/views/users/users.html b/app/portainer/views/users/users.html index 047721771..78ae271b9 100644 --- a/app/portainer/views/users/users.html +++ b/app/portainer/views/users/users.html @@ -1,5 +1,5 @@ - + @@ -10,7 +10,7 @@
- + @@ -115,7 +115,7 @@
Date: Mon, 11 Jun 2018 15:13:19 +0200 Subject: [PATCH 14/37] feat(stacks): support compose v2.0 stack (#1963) --- api/bolt/stack_service.go | 56 +- api/cmd/portainer/main.go | 16 +- api/errors.go | 1 + api/exec/{stack_manager.go => swarm_stack.go} | 22 +- api/filesystem/filesystem.go | 38 +- api/http/error/error.go | 42 +- api/http/handler/auth.go | 126 --- api/http/handler/auth/authenticate.go | 79 ++ api/http/handler/auth/handler.go | 41 + api/http/handler/azure.go | 102 -- api/http/handler/docker.go | 92 -- api/http/handler/dockerhub.go | 91 -- .../handler/dockerhub/dockerhub_inspect.go | 19 + .../handler/dockerhub/dockerhub_update.go | 52 + api/http/handler/dockerhub/handler.go | 33 + api/http/handler/endpoint.go | 625 ------------ api/http/handler/endpoint_group.go | 364 ------- .../endpointgroups/endpointgroup_create.go | 63 ++ .../endpointgroups/endpointgroup_delete.go | 51 + .../endpointgroups/endpointgroup_inspect.go | 27 + .../endpointgroups/endpointgroup_list.go | 25 + .../endpointgroups/endpointgroup_update.go | 71 ++ .../endpointgroup_update_access.go | 63 ++ api/http/handler/endpointgroups/handler.go | 69 ++ api/http/handler/endpointproxy/handler.go | 50 + api/http/handler/endpointproxy/proxy_azure.go | 53 + .../handler/endpointproxy/proxy_docker.go | 53 + .../handler/endpointproxy/proxy_storidge.go | 66 ++ api/http/handler/endpoints/endpoint_create.go | 295 ++++++ api/http/handler/endpoints/endpoint_delete.go | 48 + .../endpoints/endpoint_extension_add.go | 73 ++ .../endpoints/endpoint_extension_remove.go | 43 + .../handler/endpoints/endpoint_inspect.go | 27 + api/http/handler/endpoints/endpoint_list.go | 34 + api/http/handler/endpoints/endpoint_update.go | 137 +++ .../endpoints/endpoint_update_access.go | 67 ++ api/http/handler/endpoints/handler.go | 59 ++ api/http/handler/extensions.go | 143 --- api/http/handler/extensions/storidge.go | 106 -- api/http/handler/{file.go => file/handler.go} | 19 +- api/http/handler/handler.go | 113 +-- api/http/handler/registries/handler.go | 43 + .../handler/registries/registry_create.go | 68 ++ .../handler/registries/registry_delete.go | 32 + .../handler/registries/registry_inspect.go | 28 + api/http/handler/registries/registry_list.go | 29 + .../handler/registries/registry_update.go | 82 ++ .../registries/registry_update_access.go | 63 ++ api/http/handler/registry.go | 320 ------ api/http/handler/resource_control.go | 266 ----- api/http/handler/resourcecontrols/handler.go | 31 + .../resourcecontrol_create.go | 116 +++ .../resourcecontrol_delete.go | 42 + .../resourcecontrol_update.go | 83 ++ api/http/handler/settings.go | 177 ---- api/http/handler/settings/handler.go | 35 + api/http/handler/settings/settings_inspect.go | 18 + .../handler/settings/settings_ldap_check.go | 40 + api/http/handler/settings/settings_public.go | 35 + api/http/handler/settings/settings_update.go | 85 ++ api/http/handler/stack.go | 794 --------------- .../handler/stacks/create_compose_stack.go | 302 ++++++ api/http/handler/stacks/create_swarm_stack.go | 334 +++++++ api/http/handler/stacks/git.go | 16 + api/http/handler/stacks/handler.go | 69 ++ api/http/handler/stacks/stack_create.go | 99 ++ api/http/handler/stacks/stack_delete.go | 127 +++ api/http/handler/stacks/stack_file.go | 58 ++ api/http/handler/stacks/stack_inspect.go | 48 + api/http/handler/stacks/stack_list.go | 65 ++ api/http/handler/stacks/stack_update.go | 146 +++ api/http/handler/status.go | 38 - api/http/handler/status/handler.go | 28 + api/http/handler/status/status_inspect.go | 13 + api/http/handler/team.go | 262 ----- api/http/handler/team_membership.go | 242 ----- api/http/handler/teammemberships/handler.go | 35 + .../teammemberships/teammembership_create.go | 74 ++ .../teammemberships/teammembership_delete.go | 42 + .../teammemberships/teammembership_list.go | 29 + .../teammemberships/teammembership_update.go | 75 ++ api/http/handler/teams/handler.go | 39 + api/http/handler/teams/team_create.go | 49 + api/http/handler/teams/team_delete.go | 37 + api/http/handler/teams/team_inspect.go | 37 + api/http/handler/teams/team_list.go | 26 + api/http/handler/teams/team_memberships.go | 35 + api/http/handler/teams/team_update.go | 50 + api/http/handler/templates.go | 74 -- api/http/handler/templates/handler.go | 30 + api/http/handler/templates/template_list.go | 50 + api/http/handler/upload.go | 69 -- api/http/handler/upload/handler.go | 27 + api/http/handler/upload/upload_tls.go | 47 + api/http/handler/user.go | 463 --------- api/http/handler/users/admin_check.go | 23 + api/http/handler/users/admin_init.go | 61 ++ api/http/handler/users/handler.go | 53 + api/http/handler/users/user_create.go | 84 ++ api/http/handler/users/user_delete.go | 47 + api/http/handler/users/user_inspect.go | 28 + api/http/handler/users/user_list.go | 29 + api/http/handler/users/user_memberships.go | 35 + api/http/handler/users/user_password.go | 57 ++ api/http/handler/users/user_update.go | 75 ++ api/http/handler/websocket/handler.go | 28 + .../websocket_exec.go} | 82 +- api/http/proxy/access_control.go | 2 +- api/http/proxy/containers.go | 28 +- api/http/proxy/docker_transport.go | 2 + api/http/proxy/manager.go | 1 - api/http/proxy/socket.go | 5 +- api/http/request/request.go | 160 +++ api/http/response/response.go | 32 + api/http/security/bouncer.go | 14 +- api/http/security/filter.go | 13 +- api/http/security/rate_limiter.go | 2 +- api/http/server.go | 181 ++-- api/libcompose/compose_stack.go | 91 ++ api/portainer.go | 42 +- api/swagger.yaml | 936 ++++++++++++++---- app/constants.js | 1 + app/docker/__module.js | 39 - .../actions/containersDatatableActions.html | 35 + .../actions/containersDatatableActions.js | 12 + .../containersDatatableActionsController.js | 104 ++ .../containersDatatable.html | 45 +- .../containersDatatable.js | 10 +- .../containersDatatableController.js | 45 +- .../serviceTasksDatatable.html | 82 ++ .../serviceTasksDatatable.js | 15 + .../serviceTasksDatatableController.js | 70 ++ .../actions/servicesDatatableActions.html | 15 + .../actions/servicesDatatableActions.js | 10 + .../servicesDatatableActionsController.js | 81 ++ .../services-datatable/servicesDatatable.html | 75 +- .../services-datatable/servicesDatatable.js | 13 +- .../servicesDatatableController.js | 125 +++ .../tasks-datatable/tasksDatatable.html | 9 +- .../dockerSidebarContent.html | 4 +- app/docker/filters/filters.js | 32 +- app/docker/models/container.js | 23 +- app/docker/services/networkService.js | 4 +- app/docker/services/stackService.js | 178 ---- app/docker/views/containers/containers.html | 10 +- .../views/containers/containersController.js | 130 +-- app/docker/views/dashboard/dashboard.html | 7 +- .../views/dashboard/dashboardController.js | 16 +- .../views/networks/networksController.js | 2 +- .../create/createServiceController.js | 2 +- app/docker/views/services/services.html | 13 +- .../views/services/servicesController.js | 127 +-- app/docker/views/stacks/edit/stack.html | 133 --- .../views/stacks/edit/stackController.js | 111 --- app/docker/views/stacks/stacks.html | 51 - app/docker/views/tasks/edit/task.html | 2 +- .../views/templates/templatesController.js | 9 +- app/portainer/__module.js | 36 + .../stacks-datatable/stacksDatatable.html | 28 +- .../stacks-datatable/stacksDatatable.js | 3 +- .../stacksDatatableController.js | 2 +- app/portainer/helpers/stackHelper.js | 15 +- app/{docker => portainer}/models/stack.js | 10 +- app/portainer/rest/stack.js | 10 +- app/portainer/services/api/stackService.js | 276 ++++++ app/portainer/services/datatableService.js | 16 + app/portainer/services/fileUpload.js | 16 +- app/portainer/services/localStorage.js | 12 + .../stacks/create/createStackController.js | 60 +- .../views/stacks/create/createstack.html | 57 +- app/portainer/views/stacks/edit/stack.html | 158 +++ .../views/stacks/edit/stackController.js | 171 ++++ app/portainer/views/stacks/stacks.html | 20 + .../views/stacks/stacksController.js | 30 +- 174 files changed, 7898 insertions(+), 5849 deletions(-) rename api/exec/{stack_manager.go => swarm_stack.go} (82%) delete mode 100644 api/http/handler/auth.go create mode 100644 api/http/handler/auth/authenticate.go create mode 100644 api/http/handler/auth/handler.go delete mode 100644 api/http/handler/azure.go delete mode 100644 api/http/handler/docker.go delete mode 100644 api/http/handler/dockerhub.go create mode 100644 api/http/handler/dockerhub/dockerhub_inspect.go create mode 100644 api/http/handler/dockerhub/dockerhub_update.go create mode 100644 api/http/handler/dockerhub/handler.go delete mode 100644 api/http/handler/endpoint.go delete mode 100644 api/http/handler/endpoint_group.go create mode 100644 api/http/handler/endpointgroups/endpointgroup_create.go create mode 100644 api/http/handler/endpointgroups/endpointgroup_delete.go create mode 100644 api/http/handler/endpointgroups/endpointgroup_inspect.go create mode 100644 api/http/handler/endpointgroups/endpointgroup_list.go create mode 100644 api/http/handler/endpointgroups/endpointgroup_update.go create mode 100644 api/http/handler/endpointgroups/endpointgroup_update_access.go create mode 100644 api/http/handler/endpointgroups/handler.go create mode 100644 api/http/handler/endpointproxy/handler.go create mode 100644 api/http/handler/endpointproxy/proxy_azure.go create mode 100644 api/http/handler/endpointproxy/proxy_docker.go create mode 100644 api/http/handler/endpointproxy/proxy_storidge.go create mode 100644 api/http/handler/endpoints/endpoint_create.go create mode 100644 api/http/handler/endpoints/endpoint_delete.go create mode 100644 api/http/handler/endpoints/endpoint_extension_add.go create mode 100644 api/http/handler/endpoints/endpoint_extension_remove.go create mode 100644 api/http/handler/endpoints/endpoint_inspect.go create mode 100644 api/http/handler/endpoints/endpoint_list.go create mode 100644 api/http/handler/endpoints/endpoint_update.go create mode 100644 api/http/handler/endpoints/endpoint_update_access.go create mode 100644 api/http/handler/endpoints/handler.go delete mode 100644 api/http/handler/extensions.go delete mode 100644 api/http/handler/extensions/storidge.go rename api/http/handler/{file.go => file/handler.go} (54%) create mode 100644 api/http/handler/registries/handler.go create mode 100644 api/http/handler/registries/registry_create.go create mode 100644 api/http/handler/registries/registry_delete.go create mode 100644 api/http/handler/registries/registry_inspect.go create mode 100644 api/http/handler/registries/registry_list.go create mode 100644 api/http/handler/registries/registry_update.go create mode 100644 api/http/handler/registries/registry_update_access.go delete mode 100644 api/http/handler/registry.go delete mode 100644 api/http/handler/resource_control.go create mode 100644 api/http/handler/resourcecontrols/handler.go create mode 100644 api/http/handler/resourcecontrols/resourcecontrol_create.go create mode 100644 api/http/handler/resourcecontrols/resourcecontrol_delete.go create mode 100644 api/http/handler/resourcecontrols/resourcecontrol_update.go delete mode 100644 api/http/handler/settings.go create mode 100644 api/http/handler/settings/handler.go create mode 100644 api/http/handler/settings/settings_inspect.go create mode 100644 api/http/handler/settings/settings_ldap_check.go create mode 100644 api/http/handler/settings/settings_public.go create mode 100644 api/http/handler/settings/settings_update.go delete mode 100644 api/http/handler/stack.go create mode 100644 api/http/handler/stacks/create_compose_stack.go create mode 100644 api/http/handler/stacks/create_swarm_stack.go create mode 100644 api/http/handler/stacks/git.go create mode 100644 api/http/handler/stacks/handler.go create mode 100644 api/http/handler/stacks/stack_create.go create mode 100644 api/http/handler/stacks/stack_delete.go create mode 100644 api/http/handler/stacks/stack_file.go create mode 100644 api/http/handler/stacks/stack_inspect.go create mode 100644 api/http/handler/stacks/stack_list.go create mode 100644 api/http/handler/stacks/stack_update.go delete mode 100644 api/http/handler/status.go create mode 100644 api/http/handler/status/handler.go create mode 100644 api/http/handler/status/status_inspect.go delete mode 100644 api/http/handler/team.go delete mode 100644 api/http/handler/team_membership.go create mode 100644 api/http/handler/teammemberships/handler.go create mode 100644 api/http/handler/teammemberships/teammembership_create.go create mode 100644 api/http/handler/teammemberships/teammembership_delete.go create mode 100644 api/http/handler/teammemberships/teammembership_list.go create mode 100644 api/http/handler/teammemberships/teammembership_update.go create mode 100644 api/http/handler/teams/handler.go create mode 100644 api/http/handler/teams/team_create.go create mode 100644 api/http/handler/teams/team_delete.go create mode 100644 api/http/handler/teams/team_inspect.go create mode 100644 api/http/handler/teams/team_list.go create mode 100644 api/http/handler/teams/team_memberships.go create mode 100644 api/http/handler/teams/team_update.go delete mode 100644 api/http/handler/templates.go create mode 100644 api/http/handler/templates/handler.go create mode 100644 api/http/handler/templates/template_list.go delete mode 100644 api/http/handler/upload.go create mode 100644 api/http/handler/upload/handler.go create mode 100644 api/http/handler/upload/upload_tls.go delete mode 100644 api/http/handler/user.go create mode 100644 api/http/handler/users/admin_check.go create mode 100644 api/http/handler/users/admin_init.go create mode 100644 api/http/handler/users/handler.go create mode 100644 api/http/handler/users/user_create.go create mode 100644 api/http/handler/users/user_delete.go create mode 100644 api/http/handler/users/user_inspect.go create mode 100644 api/http/handler/users/user_list.go create mode 100644 api/http/handler/users/user_memberships.go create mode 100644 api/http/handler/users/user_password.go create mode 100644 api/http/handler/users/user_update.go create mode 100644 api/http/handler/websocket/handler.go rename api/http/handler/{websocket.go => websocket/websocket_exec.go} (73%) create mode 100644 api/http/request/request.go create mode 100644 api/http/response/response.go create mode 100644 api/libcompose/compose_stack.go create mode 100644 app/docker/components/datatables/containers-datatable/actions/containersDatatableActions.html create mode 100644 app/docker/components/datatables/containers-datatable/actions/containersDatatableActions.js create mode 100644 app/docker/components/datatables/containers-datatable/actions/containersDatatableActionsController.js create mode 100644 app/docker/components/datatables/service-tasks-datatable/serviceTasksDatatable.html create mode 100644 app/docker/components/datatables/service-tasks-datatable/serviceTasksDatatable.js create mode 100644 app/docker/components/datatables/service-tasks-datatable/serviceTasksDatatableController.js create mode 100644 app/docker/components/datatables/services-datatable/actions/servicesDatatableActions.html create mode 100644 app/docker/components/datatables/services-datatable/actions/servicesDatatableActions.js create mode 100644 app/docker/components/datatables/services-datatable/actions/servicesDatatableActionsController.js create mode 100644 app/docker/components/datatables/services-datatable/servicesDatatableController.js delete mode 100644 app/docker/services/stackService.js delete mode 100644 app/docker/views/stacks/edit/stack.html delete mode 100644 app/docker/views/stacks/edit/stackController.js delete mode 100644 app/docker/views/stacks/stacks.html rename app/{docker => portainer}/models/stack.js (60%) create mode 100644 app/portainer/services/api/stackService.js rename app/{docker => portainer}/views/stacks/create/createStackController.js (57%) rename app/{docker => portainer}/views/stacks/create/createstack.html (80%) create mode 100644 app/portainer/views/stacks/edit/stack.html create mode 100644 app/portainer/views/stacks/edit/stackController.js create mode 100644 app/portainer/views/stacks/stacks.html rename app/{docker => portainer}/views/stacks/stacksController.js (73%) diff --git a/api/bolt/stack_service.go b/api/bolt/stack_service.go index bdbaac791..7fe023546 100644 --- a/api/bolt/stack_service.go +++ b/api/bolt/stack_service.go @@ -38,6 +38,35 @@ func (service *StackService) Stack(ID portainer.StackID) (*portainer.Stack, erro return &stack, nil } +// StackByName returns a stack object by name. +func (service *StackService) StackByName(name string) (*portainer.Stack, error) { + var stack *portainer.Stack + + err := service.store.db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(stackBucketName)) + cursor := bucket.Cursor() + for k, v := cursor.First(); k != nil; k, v = cursor.Next() { + var t portainer.Stack + err := internal.UnmarshalStack(v, &t) + if err != nil { + return err + } + if t.Name == name { + stack = &t + } + } + + if stack == nil { + return portainer.ErrStackNotFound + } + return nil + }) + if err != nil { + return nil, err + } + return stack, nil +} + // Stacks returns an array containing all the stacks. func (service *StackService) Stacks() ([]portainer.Stack, error) { var stacks = make([]portainer.Stack, 0) @@ -63,33 +92,6 @@ func (service *StackService) Stacks() ([]portainer.Stack, error) { return stacks, nil } -// StacksBySwarmID return an array containing all the stacks related to the specified Swarm ID. -func (service *StackService) StacksBySwarmID(id string) ([]portainer.Stack, error) { - var stacks = make([]portainer.Stack, 0) - err := service.store.db.View(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(stackBucketName)) - - cursor := bucket.Cursor() - for k, v := cursor.First(); k != nil; k, v = cursor.Next() { - var stack portainer.Stack - err := internal.UnmarshalStack(v, &stack) - if err != nil { - return err - } - if stack.SwarmID == id { - stacks = append(stacks, stack) - } - } - - return nil - }) - if err != nil { - return nil, err - } - - return stacks, nil -} - // CreateStack creates a new stack. func (service *StackService) CreateStack(stack *portainer.Stack) error { return service.store.db.Update(func(tx *bolt.Tx) error { diff --git a/api/cmd/portainer/main.go b/api/cmd/portainer/main.go index 2ed222d33..0c13e118f 100644 --- a/api/cmd/portainer/main.go +++ b/api/cmd/portainer/main.go @@ -15,6 +15,7 @@ import ( "github.com/portainer/portainer/http/client" "github.com/portainer/portainer/jwt" "github.com/portainer/portainer/ldap" + "github.com/portainer/portainer/libcompose" "log" ) @@ -64,8 +65,12 @@ func initStore(dataStorePath string) *bolt.Store { return store } -func initStackManager(assetsPath string, dataStorePath string, signatureService portainer.DigitalSignatureService, fileService portainer.FileService) (portainer.StackManager, error) { - return exec.NewStackManager(assetsPath, dataStorePath, signatureService, fileService) +func initComposeStackManager(dataStorePath string) portainer.ComposeStackManager { + return libcompose.NewComposeStackManager(dataStorePath) +} + +func initSwarmStackManager(assetsPath string, dataStorePath string, signatureService portainer.DigitalSignatureService, fileService portainer.FileService) (portainer.SwarmStackManager, error) { + return exec.NewSwarmStackManager(assetsPath, dataStorePath, signatureService, fileService) } func initJWTService(authenticationEnabled bool) portainer.JWTService { @@ -320,11 +325,13 @@ func main() { log.Fatal(err) } - stackManager, err := initStackManager(*flags.Assets, *flags.Data, digitalSignatureService, fileService) + swarmStackManager, err := initSwarmStackManager(*flags.Assets, *flags.Data, digitalSignatureService, fileService) if err != nil { log.Fatal(err) } + composeStackManager := initComposeStackManager(*flags.Data) + err = initSettings(store.SettingsService, flags) if err != nil { log.Fatal(err) @@ -394,7 +401,8 @@ func main() { RegistryService: store.RegistryService, DockerHubService: store.DockerHubService, StackService: store.StackService, - StackManager: stackManager, + SwarmStackManager: swarmStackManager, + ComposeStackManager: composeStackManager, CryptoService: cryptoService, JWTService: jwtService, FileService: fileService, diff --git a/api/errors.go b/api/errors.go index 854cefbab..c8f534f2c 100644 --- a/api/errors.go +++ b/api/errors.go @@ -65,6 +65,7 @@ const ( ErrStackNotFound = Error("Stack not found") ErrStackAlreadyExists = Error("A stack already exists with this name") ErrComposeFileNotFoundInRepository = Error("Unable to find a Compose file in the repository") + ErrStackNotExternal = Error("Not an external stack") ) // Endpoint extensions error diff --git a/api/exec/stack_manager.go b/api/exec/swarm_stack.go similarity index 82% rename from api/exec/stack_manager.go rename to api/exec/swarm_stack.go index d8c2d9438..1e896971e 100644 --- a/api/exec/stack_manager.go +++ b/api/exec/swarm_stack.go @@ -11,18 +11,18 @@ import ( "github.com/portainer/portainer" ) -// StackManager represents a service for managing stacks. -type StackManager struct { +// SwarmStackManager represents a service for managing stacks. +type SwarmStackManager struct { binaryPath string dataPath string signatureService portainer.DigitalSignatureService fileService portainer.FileService } -// NewStackManager initializes a new StackManager service. +// NewSwarmStackManager initializes a new SwarmStackManager service. // It also updates the configuration of the Docker CLI binary. -func NewStackManager(binaryPath, dataPath string, signatureService portainer.DigitalSignatureService, fileService portainer.FileService) (*StackManager, error) { - manager := &StackManager{ +func NewSwarmStackManager(binaryPath, dataPath string, signatureService portainer.DigitalSignatureService, fileService portainer.FileService) (*SwarmStackManager, error) { + manager := &SwarmStackManager{ binaryPath: binaryPath, dataPath: dataPath, signatureService: signatureService, @@ -38,7 +38,7 @@ func NewStackManager(binaryPath, dataPath string, signatureService portainer.Dig } // Login executes the docker login command against a list of registries (including DockerHub). -func (manager *StackManager) Login(dockerhub *portainer.DockerHub, registries []portainer.Registry, endpoint *portainer.Endpoint) { +func (manager *SwarmStackManager) Login(dockerhub *portainer.DockerHub, registries []portainer.Registry, endpoint *portainer.Endpoint) { command, args := prepareDockerCommandAndArgs(manager.binaryPath, manager.dataPath, endpoint) for _, registry := range registries { if registry.Authentication { @@ -54,14 +54,14 @@ func (manager *StackManager) Login(dockerhub *portainer.DockerHub, registries [] } // Logout executes the docker logout command. -func (manager *StackManager) Logout(endpoint *portainer.Endpoint) error { +func (manager *SwarmStackManager) Logout(endpoint *portainer.Endpoint) error { command, args := prepareDockerCommandAndArgs(manager.binaryPath, manager.dataPath, endpoint) args = append(args, "logout") return runCommandAndCaptureStdErr(command, args, nil, "") } // Deploy executes the docker stack deploy command. -func (manager *StackManager) Deploy(stack *portainer.Stack, prune bool, endpoint *portainer.Endpoint) error { +func (manager *SwarmStackManager) Deploy(stack *portainer.Stack, prune bool, endpoint *portainer.Endpoint) error { stackFilePath := path.Join(stack.ProjectPath, stack.EntryPoint) command, args := prepareDockerCommandAndArgs(manager.binaryPath, manager.dataPath, endpoint) @@ -81,7 +81,7 @@ func (manager *StackManager) Deploy(stack *portainer.Stack, prune bool, endpoint } // Remove executes the docker stack rm command. -func (manager *StackManager) Remove(stack *portainer.Stack, endpoint *portainer.Endpoint) error { +func (manager *SwarmStackManager) Remove(stack *portainer.Stack, endpoint *portainer.Endpoint) error { command, args := prepareDockerCommandAndArgs(manager.binaryPath, manager.dataPath, endpoint) args = append(args, "stack", "rm", stack.Name) return runCommandAndCaptureStdErr(command, args, nil, "") @@ -133,7 +133,7 @@ func prepareDockerCommandAndArgs(binaryPath, dataPath string, endpoint *portaine return command, args } -func (manager *StackManager) updateDockerCLIConfiguration(dataPath string) error { +func (manager *SwarmStackManager) updateDockerCLIConfiguration(dataPath string) error { configFilePath := path.Join(dataPath, "config.json") config, err := manager.retrieveConfigurationFromDisk(configFilePath) if err != nil { @@ -161,7 +161,7 @@ func (manager *StackManager) updateDockerCLIConfiguration(dataPath string) error return nil } -func (manager *StackManager) retrieveConfigurationFromDisk(path string) (map[string]interface{}, error) { +func (manager *SwarmStackManager) retrieveConfigurationFromDisk(path string) (map[string]interface{}, error) { var config map[string]interface{} raw, err := manager.fileService.GetFileContent(path) diff --git a/api/filesystem/filesystem.go b/api/filesystem/filesystem.go index 770b8af76..c3f459ddf 100644 --- a/api/filesystem/filesystem.go +++ b/api/filesystem/filesystem.go @@ -77,9 +77,9 @@ func (service *Service) GetStackProjectPath(stackIdentifier string) string { return path.Join(service.fileStorePath, ComposeStorePath, stackIdentifier) } -// StoreStackFileFromString creates a subfolder in the ComposeStorePath and stores a new file using the content from a string. +// StoreStackFileFromBytes creates a subfolder in the ComposeStorePath and stores a new file from bytes. // It returns the path to the folder where the file is stored. -func (service *Service) StoreStackFileFromString(stackIdentifier, fileName, stackFileContent string) (string, error) { +func (service *Service) StoreStackFileFromBytes(stackIdentifier, fileName string, data []byte) (string, error) { stackStorePath := path.Join(ComposeStorePath, stackIdentifier) err := service.createDirectoryInStore(stackStorePath) if err != nil { @@ -87,7 +87,6 @@ func (service *Service) StoreStackFileFromString(stackIdentifier, fileName, stac } composeFilePath := path.Join(stackStorePath, fileName) - data := []byte(stackFileContent) r := bytes.NewReader(data) err = service.createFileInStore(composeFilePath, r) @@ -98,31 +97,13 @@ func (service *Service) StoreStackFileFromString(stackIdentifier, fileName, stac return path.Join(service.fileStorePath, stackStorePath), nil } -// StoreStackFileFromReader creates a subfolder in the ComposeStorePath and stores a new file using the content from an io.Reader. -// It returns the path to the folder where the file is stored. -func (service *Service) StoreStackFileFromReader(stackIdentifier, fileName string, r io.Reader) (string, error) { - stackStorePath := path.Join(ComposeStorePath, stackIdentifier) - err := service.createDirectoryInStore(stackStorePath) - if err != nil { - return "", err - } - - composeFilePath := path.Join(stackStorePath, fileName) - - err = service.createFileInStore(composeFilePath, r) - if err != nil { - return "", err - } - - return path.Join(service.fileStorePath, stackStorePath), nil -} - -// StoreTLSFile creates a folder in the TLSStorePath and stores a new file with the content from r. -func (service *Service) StoreTLSFile(folder string, fileType portainer.TLSFileType, r io.Reader) error { +// StoreTLSFileFromBytes creates a folder in the TLSStorePath and stores a new file from bytes. +// It returns the path to the newly created file. +func (service *Service) StoreTLSFileFromBytes(folder string, fileType portainer.TLSFileType, data []byte) (string, error) { storePath := path.Join(TLSStorePath, folder) err := service.createDirectoryInStore(storePath) if err != nil { - return err + return "", err } var fileName string @@ -134,15 +115,16 @@ func (service *Service) StoreTLSFile(folder string, fileType portainer.TLSFileTy case portainer.TLSFileKey: fileName = TLSKeyFile default: - return portainer.ErrUndefinedTLSFileType + return "", portainer.ErrUndefinedTLSFileType } tlsFilePath := path.Join(storePath, fileName) + r := bytes.NewReader(data) err = service.createFileInStore(tlsFilePath, r) if err != nil { - return err + return "", err } - return nil + return path.Join(service.fileStorePath, tlsFilePath), nil } // GetPathForTLSFile returns the absolute path to a specific TLS file for an endpoint. diff --git a/api/http/error/error.go b/api/http/error/error.go index 7a2715c8a..b9153a8a6 100644 --- a/api/http/error/error.go +++ b/api/http/error/error.go @@ -6,18 +6,36 @@ import ( "net/http" ) -// errorResponse is a generic response for sending a error. -type errorResponse struct { - Err string `json:"err,omitempty"` -} - -// WriteErrorResponse writes an error message to the response and logger. -func WriteErrorResponse(w http.ResponseWriter, err error, code int, logger *log.Logger) { - if logger != nil { - logger.Printf("http error: %s (code=%d)", err, code) +type ( + // LoggerHandler defines a HTTP handler that includes a HandlerError return pointer + LoggerHandler func(http.ResponseWriter, *http.Request) *HandlerError + // HandlerError represents an error raised inside a HTTP handler + HandlerError struct { + StatusCode int + Message string + Err error } + errorResponse struct { + Err string `json:"err,omitempty"` + } +) - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(code) - json.NewEncoder(w).Encode(&errorResponse{Err: err.Error()}) +func (handler LoggerHandler) ServeHTTP(rw http.ResponseWriter, r *http.Request) { + err := handler(rw, r) + if err != nil { + writeErrorResponse(rw, err) + } +} + +func writeErrorResponse(rw http.ResponseWriter, err *HandlerError) { + log.Printf("http error: %s (err=%s) (code=%d)\n", err.Message, err.Err, err.StatusCode) + rw.Header().Set("Content-Type", "application/json") + rw.WriteHeader(err.StatusCode) + json.NewEncoder(rw).Encode(&errorResponse{Err: err.Message}) +} + +// WriteError is a convenience function that creates a new HandlerError before calling writeErrorResponse. +// For use outside of the standard http handlers. +func WriteError(rw http.ResponseWriter, code int, message string, err error) { + writeErrorResponse(rw, &HandlerError{code, message, err}) } diff --git a/api/http/handler/auth.go b/api/http/handler/auth.go deleted file mode 100644 index 4b75967e9..000000000 --- a/api/http/handler/auth.go +++ /dev/null @@ -1,126 +0,0 @@ -package handler - -import ( - "github.com/portainer/portainer" - - "encoding/json" - "log" - "net/http" - "os" - - "github.com/asaskevich/govalidator" - "github.com/gorilla/mux" - httperror "github.com/portainer/portainer/http/error" - "github.com/portainer/portainer/http/security" -) - -// AuthHandler represents an HTTP API handler for managing authentication. -type AuthHandler struct { - *mux.Router - Logger *log.Logger - authDisabled bool - UserService portainer.UserService - CryptoService portainer.CryptoService - JWTService portainer.JWTService - LDAPService portainer.LDAPService - SettingsService portainer.SettingsService -} - -const ( - // ErrInvalidCredentialsFormat is an error raised when credentials format is not valid - ErrInvalidCredentialsFormat = portainer.Error("Invalid credentials format") - // ErrInvalidCredentials is an error raised when credentials for a user are invalid - ErrInvalidCredentials = portainer.Error("Invalid credentials") - // ErrAuthDisabled is an error raised when trying to access the authentication endpoints - // when the server has been started with the --no-auth flag - ErrAuthDisabled = portainer.Error("Authentication is disabled") -) - -// NewAuthHandler returns a new instance of AuthHandler. -func NewAuthHandler(bouncer *security.RequestBouncer, rateLimiter *security.RateLimiter, authDisabled bool) *AuthHandler { - h := &AuthHandler{ - Router: mux.NewRouter(), - Logger: log.New(os.Stderr, "", log.LstdFlags), - authDisabled: authDisabled, - } - h.Handle("/auth", - rateLimiter.LimitAccess(bouncer.PublicAccess(http.HandlerFunc(h.handlePostAuth)))).Methods(http.MethodPost) - - return h -} - -type ( - postAuthRequest struct { - Username string `valid:"required"` - Password string `valid:"required"` - } - - postAuthResponse struct { - JWT string `json:"jwt"` - } -) - -func (handler *AuthHandler) handlePostAuth(w http.ResponseWriter, r *http.Request) { - if handler.authDisabled { - httperror.WriteErrorResponse(w, ErrAuthDisabled, http.StatusServiceUnavailable, handler.Logger) - return - } - - var req postAuthRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger) - return - } - - _, err := govalidator.ValidateStruct(req) - if err != nil { - httperror.WriteErrorResponse(w, ErrInvalidCredentialsFormat, http.StatusBadRequest, handler.Logger) - return - } - - var username = req.Username - var password = req.Password - - u, err := handler.UserService.UserByUsername(username) - if err == portainer.ErrUserNotFound { - httperror.WriteErrorResponse(w, ErrInvalidCredentials, http.StatusBadRequest, handler.Logger) - return - } else if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - settings, err := handler.SettingsService.Settings() - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - if settings.AuthenticationMethod == portainer.AuthenticationLDAP && u.ID != 1 { - err = handler.LDAPService.AuthenticateUser(username, password, &settings.LDAPSettings) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - } else { - err = handler.CryptoService.CompareHashAndData(u.Password, password) - if err != nil { - httperror.WriteErrorResponse(w, ErrInvalidCredentials, http.StatusUnprocessableEntity, handler.Logger) - return - } - } - - tokenData := &portainer.TokenData{ - ID: u.ID, - Username: u.Username, - Role: u.Role, - } - - token, err := handler.JWTService.GenerateToken(tokenData) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - encodeJSON(w, &postAuthResponse{JWT: token}, handler.Logger) -} diff --git a/api/http/handler/auth/authenticate.go b/api/http/handler/auth/authenticate.go new file mode 100644 index 000000000..a5200f7d3 --- /dev/null +++ b/api/http/handler/auth/authenticate.go @@ -0,0 +1,79 @@ +package auth + +import ( + "net/http" + + "github.com/asaskevich/govalidator" + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" +) + +type authenticatePayload struct { + Username string + Password string +} + +type authenticateResponse struct { + JWT string `json:"jwt"` +} + +func (payload *authenticatePayload) Validate(r *http.Request) error { + if govalidator.IsNull(payload.Username) { + return portainer.Error("Invalid username") + } + if govalidator.IsNull(payload.Password) { + return portainer.Error("Invalid password") + } + return nil +} + +func (handler *Handler) authenticate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + if handler.authDisabled { + return &httperror.HandlerError{http.StatusServiceUnavailable, "Cannot authenticate user. Portainer was started with the --no-auth flag", ErrAuthDisabled} + } + + var payload authenticatePayload + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + u, err := handler.UserService.UserByUsername(payload.Username) + if err == portainer.ErrUserNotFound { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid credentials", ErrInvalidCredentials} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a user with the specified username from the database", err} + } + + settings, err := handler.SettingsService.Settings() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve settings from the database", err} + } + + if settings.AuthenticationMethod == portainer.AuthenticationLDAP && u.ID != 1 { + err = handler.LDAPService.AuthenticateUser(payload.Username, payload.Password, &settings.LDAPSettings) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to authenticate user via LDAP/AD", err} + } + } else { + err = handler.CryptoService.CompareHashAndData(u.Password, payload.Password) + if err != nil { + return &httperror.HandlerError{http.StatusUnprocessableEntity, "Invalid credentials", ErrInvalidCredentials} + } + } + + tokenData := &portainer.TokenData{ + ID: u.ID, + Username: u.Username, + Role: u.Role, + } + + token, err := handler.JWTService.GenerateToken(tokenData) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to generate JWT token", err} + } + + return response.JSON(w, &authenticateResponse{JWT: token}) +} diff --git a/api/http/handler/auth/handler.go b/api/http/handler/auth/handler.go new file mode 100644 index 000000000..db47b82e2 --- /dev/null +++ b/api/http/handler/auth/handler.go @@ -0,0 +1,41 @@ +package auth + +import ( + "net/http" + + "github.com/gorilla/mux" + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/security" +) + +const ( + // ErrInvalidCredentials is an error raised when credentials for a user are invalid + ErrInvalidCredentials = portainer.Error("Invalid credentials") + // ErrAuthDisabled is an error raised when trying to access the authentication endpoints + // when the server has been started with the --no-auth flag + ErrAuthDisabled = portainer.Error("Authentication is disabled") +) + +// Handler is the HTTP handler used to handle authentication operations. +type Handler struct { + *mux.Router + authDisabled bool + UserService portainer.UserService + CryptoService portainer.CryptoService + JWTService portainer.JWTService + LDAPService portainer.LDAPService + SettingsService portainer.SettingsService +} + +// NewHandler creates a handler to manage authentication operations. +func NewHandler(bouncer *security.RequestBouncer, rateLimiter *security.RateLimiter, authDisabled bool) *Handler { + h := &Handler{ + Router: mux.NewRouter(), + authDisabled: authDisabled, + } + h.Handle("/auth", + rateLimiter.LimitAccess(bouncer.PublicAccess(httperror.LoggerHandler(h.authenticate)))).Methods(http.MethodPost) + + return h +} diff --git a/api/http/handler/azure.go b/api/http/handler/azure.go deleted file mode 100644 index a372244a1..000000000 --- a/api/http/handler/azure.go +++ /dev/null @@ -1,102 +0,0 @@ -package handler - -import ( - "strconv" - - "github.com/portainer/portainer" - httperror "github.com/portainer/portainer/http/error" - "github.com/portainer/portainer/http/proxy" - "github.com/portainer/portainer/http/security" - - "log" - "net/http" - "os" - - "github.com/gorilla/mux" -) - -// AzureHandler represents an HTTP API handler for proxying requests to the Azure API. -type AzureHandler struct { - *mux.Router - Logger *log.Logger - EndpointService portainer.EndpointService - EndpointGroupService portainer.EndpointGroupService - TeamMembershipService portainer.TeamMembershipService - ProxyManager *proxy.Manager -} - -// NewAzureHandler returns a new instance of AzureHandler. -func NewAzureHandler(bouncer *security.RequestBouncer) *AzureHandler { - h := &AzureHandler{ - Router: mux.NewRouter(), - Logger: log.New(os.Stderr, "", log.LstdFlags), - } - h.PathPrefix("/{id}/azure").Handler( - bouncer.AuthenticatedAccess(http.HandlerFunc(h.proxyRequestsToAzureAPI))) - return h -} - -func (handler *AzureHandler) checkEndpointAccess(endpoint *portainer.Endpoint, userID portainer.UserID) error { - memberships, err := handler.TeamMembershipService.TeamMembershipsByUserID(userID) - if err != nil { - return err - } - - group, err := handler.EndpointGroupService.EndpointGroup(endpoint.GroupID) - if err != nil { - return err - } - - if !security.AuthorizedEndpointAccess(endpoint, group, userID, memberships) { - return portainer.ErrEndpointAccessDenied - } - - return nil -} - -func (handler *AzureHandler) proxyRequestsToAzureAPI(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - id := vars["id"] - - parsedID, err := strconv.Atoi(id) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) - return - } - - endpointID := portainer.EndpointID(parsedID) - endpoint, err := handler.EndpointService.Endpoint(endpointID) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - tokenData, err := security.RetrieveTokenData(r) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - if tokenData.Role != portainer.AdministratorRole { - err = handler.checkEndpointAccess(endpoint, tokenData.ID) - if err != nil && err == portainer.ErrEndpointAccessDenied { - httperror.WriteErrorResponse(w, err, http.StatusForbidden, handler.Logger) - return - } else if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - } - - var proxy http.Handler - proxy = handler.ProxyManager.GetProxy(string(endpointID)) - if proxy == nil { - proxy, err = handler.ProxyManager.CreateAndRegisterProxy(endpoint) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - } - - http.StripPrefix("/"+id+"/azure", proxy).ServeHTTP(w, r) -} diff --git a/api/http/handler/docker.go b/api/http/handler/docker.go deleted file mode 100644 index 24cf0831d..000000000 --- a/api/http/handler/docker.go +++ /dev/null @@ -1,92 +0,0 @@ -package handler - -import ( - "strconv" - - "github.com/portainer/portainer" - httperror "github.com/portainer/portainer/http/error" - "github.com/portainer/portainer/http/proxy" - "github.com/portainer/portainer/http/security" - - "log" - "net/http" - "os" - - "github.com/gorilla/mux" -) - -// DockerHandler represents an HTTP API handler for proxying requests to the Docker API. -type DockerHandler struct { - *mux.Router - Logger *log.Logger - EndpointService portainer.EndpointService - EndpointGroupService portainer.EndpointGroupService - TeamMembershipService portainer.TeamMembershipService - ProxyManager *proxy.Manager -} - -// NewDockerHandler returns a new instance of DockerHandler. -func NewDockerHandler(bouncer *security.RequestBouncer) *DockerHandler { - h := &DockerHandler{ - Router: mux.NewRouter(), - Logger: log.New(os.Stderr, "", log.LstdFlags), - } - h.PathPrefix("/{id}/docker").Handler( - bouncer.AuthenticatedAccess(http.HandlerFunc(h.proxyRequestsToDockerAPI))) - return h -} - -func (handler *DockerHandler) proxyRequestsToDockerAPI(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - id := vars["id"] - - parsedID, err := strconv.Atoi(id) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) - return - } - - endpointID := portainer.EndpointID(parsedID) - endpoint, err := handler.EndpointService.Endpoint(endpointID) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - tokenData, err := security.RetrieveTokenData(r) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - memberships, err := handler.TeamMembershipService.TeamMembershipsByUserID(tokenData.ID) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - if tokenData.Role != portainer.AdministratorRole { - group, err := handler.EndpointGroupService.EndpointGroup(endpoint.GroupID) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - if !security.AuthorizedEndpointAccess(endpoint, group, tokenData.ID, memberships) { - httperror.WriteErrorResponse(w, portainer.ErrEndpointAccessDenied, http.StatusForbidden, handler.Logger) - return - } - } - - var proxy http.Handler - proxy = handler.ProxyManager.GetProxy(string(endpointID)) - if proxy == nil { - proxy, err = handler.ProxyManager.CreateAndRegisterProxy(endpoint) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - } - - http.StripPrefix("/"+id+"/docker", proxy).ServeHTTP(w, r) -} diff --git a/api/http/handler/dockerhub.go b/api/http/handler/dockerhub.go deleted file mode 100644 index 75acc0517..000000000 --- a/api/http/handler/dockerhub.go +++ /dev/null @@ -1,91 +0,0 @@ -package handler - -import ( - "encoding/json" - - "github.com/asaskevich/govalidator" - "github.com/portainer/portainer" - httperror "github.com/portainer/portainer/http/error" - "github.com/portainer/portainer/http/security" - - "log" - "net/http" - "os" - - "github.com/gorilla/mux" -) - -// DockerHubHandler represents an HTTP API handler for managing DockerHub. -type DockerHubHandler struct { - *mux.Router - Logger *log.Logger - DockerHubService portainer.DockerHubService -} - -// NewDockerHubHandler returns a new instance of DockerHubHandler. -func NewDockerHubHandler(bouncer *security.RequestBouncer) *DockerHubHandler { - h := &DockerHubHandler{ - Router: mux.NewRouter(), - Logger: log.New(os.Stderr, "", log.LstdFlags), - } - h.Handle("/dockerhub", - bouncer.AuthenticatedAccess(http.HandlerFunc(h.handleGetDockerHub))).Methods(http.MethodGet) - h.Handle("/dockerhub", - bouncer.AdministratorAccess(http.HandlerFunc(h.handlePutDockerHub))).Methods(http.MethodPut) - - return h -} - -type ( - putDockerHubRequest struct { - Authentication bool `valid:""` - Username string `valid:""` - Password string `valid:""` - } -) - -// handleGetDockerHub handles GET requests on /dockerhub -func (handler *DockerHubHandler) handleGetDockerHub(w http.ResponseWriter, r *http.Request) { - dockerhub, err := handler.DockerHubService.DockerHub() - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - dockerhub.Password = "" - - encodeJSON(w, dockerhub, handler.Logger) - return -} - -// handlePutDockerHub handles PUT requests on /dockerhub -func (handler *DockerHubHandler) handlePutDockerHub(w http.ResponseWriter, r *http.Request) { - var req putDockerHubRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger) - return - } - - _, err := govalidator.ValidateStruct(req) - if err != nil { - httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) - return - } - - dockerhub := &portainer.DockerHub{ - Authentication: false, - Username: "", - Password: "", - } - - if req.Authentication { - dockerhub.Authentication = true - dockerhub.Username = req.Username - dockerhub.Password = req.Password - } - - err = handler.DockerHubService.StoreDockerHub(dockerhub) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - } -} diff --git a/api/http/handler/dockerhub/dockerhub_inspect.go b/api/http/handler/dockerhub/dockerhub_inspect.go new file mode 100644 index 000000000..25be6617c --- /dev/null +++ b/api/http/handler/dockerhub/dockerhub_inspect.go @@ -0,0 +1,19 @@ +package dockerhub + +import ( + "net/http" + + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/response" +) + +// GET request on /api/dockerhub +func (handler *Handler) dockerhubInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + dockerhub, err := handler.DockerHubService.DockerHub() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve DockerHub details from the database", err} + } + + hideFields(dockerhub) + return response.JSON(w, dockerhub) +} diff --git a/api/http/handler/dockerhub/dockerhub_update.go b/api/http/handler/dockerhub/dockerhub_update.go new file mode 100644 index 000000000..9f8f8d201 --- /dev/null +++ b/api/http/handler/dockerhub/dockerhub_update.go @@ -0,0 +1,52 @@ +package dockerhub + +import ( + "net/http" + + "github.com/asaskevich/govalidator" + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" +) + +type dockerhubUpdatePayload struct { + Authentication bool + Username string + Password string +} + +func (payload *dockerhubUpdatePayload) Validate(r *http.Request) error { + if payload.Authentication && (govalidator.IsNull(payload.Username) || govalidator.IsNull(payload.Password)) { + return portainer.Error("Invalid credentials. Username and password must be specified when authentication is enabled") + } + return nil +} + +// PUT request on /api/dockerhub +func (handler *Handler) dockerhubUpdate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + var payload dockerhubUpdatePayload + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + dockerhub := &portainer.DockerHub{ + Authentication: false, + Username: "", + Password: "", + } + + if payload.Authentication { + dockerhub.Authentication = true + dockerhub.Username = payload.Username + dockerhub.Password = payload.Password + } + + err = handler.DockerHubService.StoreDockerHub(dockerhub) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the Dockerhub changes inside the database", err} + } + + return response.Empty(w) +} diff --git a/api/http/handler/dockerhub/handler.go b/api/http/handler/dockerhub/handler.go new file mode 100644 index 000000000..cd2f5ae50 --- /dev/null +++ b/api/http/handler/dockerhub/handler.go @@ -0,0 +1,33 @@ +package dockerhub + +import ( + "net/http" + + "github.com/gorilla/mux" + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/security" +) + +func hideFields(dockerHub *portainer.DockerHub) { + dockerHub.Password = "" +} + +// Handler is the HTTP handler used to handle DockerHub operations. +type Handler struct { + *mux.Router + DockerHubService portainer.DockerHubService +} + +// NewHandler creates a handler to manage Dockerhub operations. +func NewHandler(bouncer *security.RequestBouncer) *Handler { + h := &Handler{ + Router: mux.NewRouter(), + } + h.Handle("/dockerhub", + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.dockerhubInspect))).Methods(http.MethodGet) + h.Handle("/dockerhub", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.dockerhubUpdate))).Methods(http.MethodPut) + + return h +} diff --git a/api/http/handler/endpoint.go b/api/http/handler/endpoint.go deleted file mode 100644 index 4b6a43220..000000000 --- a/api/http/handler/endpoint.go +++ /dev/null @@ -1,625 +0,0 @@ -package handler - -import ( - "bytes" - "strings" - - "github.com/portainer/portainer" - "github.com/portainer/portainer/crypto" - "github.com/portainer/portainer/http/client" - httperror "github.com/portainer/portainer/http/error" - "github.com/portainer/portainer/http/proxy" - "github.com/portainer/portainer/http/security" - - "encoding/json" - "log" - "net/http" - "os" - "strconv" - - "github.com/asaskevich/govalidator" - "github.com/gorilla/mux" -) - -// EndpointHandler represents an HTTP API handler for managing Docker endpoints. -type EndpointHandler struct { - *mux.Router - Logger *log.Logger - authorizeEndpointManagement bool - EndpointService portainer.EndpointService - EndpointGroupService portainer.EndpointGroupService - FileService portainer.FileService - ProxyManager *proxy.Manager -} - -const ( - // ErrEndpointManagementDisabled is an error raised when trying to access the endpoints management endpoints - // when the server has been started with the --external-endpoints flag - ErrEndpointManagementDisabled = portainer.Error("Endpoint management is disabled") -) - -// NewEndpointHandler returns a new instance of EndpointHandler. -func NewEndpointHandler(bouncer *security.RequestBouncer, authorizeEndpointManagement bool) *EndpointHandler { - h := &EndpointHandler{ - Router: mux.NewRouter(), - Logger: log.New(os.Stderr, "", log.LstdFlags), - authorizeEndpointManagement: authorizeEndpointManagement, - } - h.Handle("/endpoints", - bouncer.AdministratorAccess(http.HandlerFunc(h.handlePostEndpoints))).Methods(http.MethodPost) - h.Handle("/endpoints", - bouncer.RestrictedAccess(http.HandlerFunc(h.handleGetEndpoints))).Methods(http.MethodGet) - h.Handle("/endpoints/{id}", - bouncer.AdministratorAccess(http.HandlerFunc(h.handleGetEndpoint))).Methods(http.MethodGet) - h.Handle("/endpoints/{id}", - bouncer.AdministratorAccess(http.HandlerFunc(h.handlePutEndpoint))).Methods(http.MethodPut) - h.Handle("/endpoints/{id}/access", - bouncer.AdministratorAccess(http.HandlerFunc(h.handlePutEndpointAccess))).Methods(http.MethodPut) - h.Handle("/endpoints/{id}", - bouncer.AdministratorAccess(http.HandlerFunc(h.handleDeleteEndpoint))).Methods(http.MethodDelete) - - return h -} - -type ( - putEndpointAccessRequest struct { - AuthorizedUsers []int `valid:"-"` - AuthorizedTeams []int `valid:"-"` - } - - putEndpointsRequest struct { - Name string `valid:"-"` - URL string `valid:"-"` - PublicURL string `valid:"-"` - GroupID int `valid:"-"` - TLS bool `valid:"-"` - TLSSkipVerify bool `valid:"-"` - TLSSkipClientVerify bool `valid:"-"` - AzureApplicationID string `valid:"-"` - AzureTenantID string `valid:"-"` - AzureAuthenticationKey string `valid:"-"` - } - - postEndpointPayload struct { - name string - url string - endpointType int - publicURL string - groupID int - useTLS bool - skipTLSServerVerification bool - skipTLSClientVerification bool - caCert []byte - cert []byte - key []byte - azureApplicationID string - azureTenantID string - azureAuthenticationKey string - } -) - -// handleGetEndpoints handles GET requests on /endpoints -func (handler *EndpointHandler) handleGetEndpoints(w http.ResponseWriter, r *http.Request) { - securityContext, err := security.RetrieveRestrictedRequestContext(r) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - endpoints, err := handler.EndpointService.Endpoints() - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - groups, err := handler.EndpointGroupService.EndpointGroups() - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - filteredEndpoints, err := security.FilterEndpoints(endpoints, groups, securityContext) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - for i := range filteredEndpoints { - filteredEndpoints[i].AzureCredentials = portainer.AzureCredentials{} - } - - encodeJSON(w, filteredEndpoints, handler.Logger) -} - -func (handler *EndpointHandler) createAzureEndpoint(payload *postEndpointPayload) (*portainer.Endpoint, error) { - credentials := portainer.AzureCredentials{ - ApplicationID: payload.azureApplicationID, - TenantID: payload.azureTenantID, - AuthenticationKey: payload.azureAuthenticationKey, - } - - httpClient := client.NewHTTPClient() - _, err := httpClient.ExecuteAzureAuthenticationRequest(&credentials) - if err != nil { - return nil, err - } - - endpoint := &portainer.Endpoint{ - Name: payload.name, - URL: proxy.AzureAPIBaseURL, - Type: portainer.AzureEnvironment, - GroupID: portainer.EndpointGroupID(payload.groupID), - PublicURL: payload.publicURL, - AuthorizedUsers: []portainer.UserID{}, - AuthorizedTeams: []portainer.TeamID{}, - Extensions: []portainer.EndpointExtension{}, - AzureCredentials: credentials, - } - - err = handler.EndpointService.CreateEndpoint(endpoint) - if err != nil { - return nil, err - } - - return endpoint, nil -} - -func (handler *EndpointHandler) createTLSSecuredEndpoint(payload *postEndpointPayload) (*portainer.Endpoint, error) { - tlsConfig, err := crypto.CreateTLSConfigurationFromBytes(payload.caCert, payload.cert, payload.key, payload.skipTLSClientVerification, payload.skipTLSServerVerification) - if err != nil { - return nil, err - } - - agentOnDockerEnvironment, err := client.ExecutePingOperation(payload.url, tlsConfig) - if err != nil { - return nil, err - } - - endpointType := portainer.DockerEnvironment - if agentOnDockerEnvironment { - endpointType = portainer.AgentOnDockerEnvironment - } - - endpoint := &portainer.Endpoint{ - Name: payload.name, - URL: payload.url, - Type: endpointType, - GroupID: portainer.EndpointGroupID(payload.groupID), - PublicURL: payload.publicURL, - TLSConfig: portainer.TLSConfiguration{ - TLS: payload.useTLS, - TLSSkipVerify: payload.skipTLSServerVerification, - }, - AuthorizedUsers: []portainer.UserID{}, - AuthorizedTeams: []portainer.TeamID{}, - Extensions: []portainer.EndpointExtension{}, - } - - err = handler.EndpointService.CreateEndpoint(endpoint) - if err != nil { - return nil, err - } - - folder := strconv.Itoa(int(endpoint.ID)) - - if !payload.skipTLSServerVerification { - r := bytes.NewReader(payload.caCert) - // TODO: review the API exposed by the FileService to store - // a file from a byte slice and return the path to the stored file instead - // of using multiple legacy calls (StoreTLSFile, GetPathForTLSFile) here. - err = handler.FileService.StoreTLSFile(folder, portainer.TLSFileCA, r) - if err != nil { - handler.EndpointService.DeleteEndpoint(endpoint.ID) - return nil, err - } - caCertPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileCA) - endpoint.TLSConfig.TLSCACertPath = caCertPath - } - - if !payload.skipTLSClientVerification { - r := bytes.NewReader(payload.cert) - err = handler.FileService.StoreTLSFile(folder, portainer.TLSFileCert, r) - if err != nil { - handler.EndpointService.DeleteEndpoint(endpoint.ID) - return nil, err - } - certPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileCert) - endpoint.TLSConfig.TLSCertPath = certPath - - r = bytes.NewReader(payload.key) - err = handler.FileService.StoreTLSFile(folder, portainer.TLSFileKey, r) - if err != nil { - handler.EndpointService.DeleteEndpoint(endpoint.ID) - return nil, err - } - keyPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileKey) - endpoint.TLSConfig.TLSKeyPath = keyPath - } - - err = handler.EndpointService.UpdateEndpoint(endpoint.ID, endpoint) - if err != nil { - return nil, err - } - - return endpoint, nil -} - -func (handler *EndpointHandler) createUnsecuredEndpoint(payload *postEndpointPayload) (*portainer.Endpoint, error) { - endpointType := portainer.DockerEnvironment - - if !strings.HasPrefix(payload.url, "unix://") { - agentOnDockerEnvironment, err := client.ExecutePingOperation(payload.url, nil) - if err != nil { - return nil, err - } - if agentOnDockerEnvironment { - endpointType = portainer.AgentOnDockerEnvironment - } - } - - endpoint := &portainer.Endpoint{ - Name: payload.name, - URL: payload.url, - Type: endpointType, - GroupID: portainer.EndpointGroupID(payload.groupID), - PublicURL: payload.publicURL, - TLSConfig: portainer.TLSConfiguration{ - TLS: false, - }, - AuthorizedUsers: []portainer.UserID{}, - AuthorizedTeams: []portainer.TeamID{}, - Extensions: []portainer.EndpointExtension{}, - } - - err := handler.EndpointService.CreateEndpoint(endpoint) - if err != nil { - return nil, err - } - - return endpoint, nil -} - -func (handler *EndpointHandler) createEndpoint(payload *postEndpointPayload) (*portainer.Endpoint, error) { - if portainer.EndpointType(payload.endpointType) == portainer.AzureEnvironment { - return handler.createAzureEndpoint(payload) - } - - if payload.useTLS { - return handler.createTLSSecuredEndpoint(payload) - } - return handler.createUnsecuredEndpoint(payload) -} - -func convertPostEndpointRequestToPayload(r *http.Request) (*postEndpointPayload, error) { - payload := &postEndpointPayload{} - payload.name = r.FormValue("Name") - - endpointType := r.FormValue("EndpointType") - - if payload.name == "" || endpointType == "" { - return nil, ErrInvalidRequestFormat - } - - parsedType, err := strconv.Atoi(endpointType) - if err != nil { - return nil, err - } - - payload.url = r.FormValue("URL") - payload.endpointType = parsedType - - if portainer.EndpointType(payload.endpointType) != portainer.AzureEnvironment && payload.url == "" { - return nil, ErrInvalidRequestFormat - } - - payload.publicURL = r.FormValue("PublicURL") - - if portainer.EndpointType(payload.endpointType) == portainer.AzureEnvironment { - payload.azureApplicationID = r.FormValue("AzureApplicationID") - payload.azureTenantID = r.FormValue("AzureTenantID") - payload.azureAuthenticationKey = r.FormValue("AzureAuthenticationKey") - - if payload.azureApplicationID == "" || payload.azureTenantID == "" || payload.azureAuthenticationKey == "" { - return nil, ErrInvalidRequestFormat - } - } - - rawGroupID := r.FormValue("GroupID") - if rawGroupID == "" { - payload.groupID = 1 - } else { - groupID, err := strconv.Atoi(rawGroupID) - if err != nil { - return nil, err - } - payload.groupID = groupID - } - - payload.useTLS = r.FormValue("TLS") == "true" - - if payload.useTLS { - payload.skipTLSServerVerification = r.FormValue("TLSSkipVerify") == "true" - payload.skipTLSClientVerification = r.FormValue("TLSSkipClientVerify") == "true" - - if !payload.skipTLSServerVerification { - caCert, err := getUploadedFileContent(r, "TLSCACertFile") - if err != nil { - return nil, err - } - payload.caCert = caCert - } - - if !payload.skipTLSClientVerification { - cert, err := getUploadedFileContent(r, "TLSCertFile") - if err != nil { - return nil, err - } - payload.cert = cert - key, err := getUploadedFileContent(r, "TLSKeyFile") - if err != nil { - return nil, err - } - payload.key = key - } - } - - return payload, nil -} - -// handlePostEndpoints handles POST requests on /endpoints -func (handler *EndpointHandler) handlePostEndpoints(w http.ResponseWriter, r *http.Request) { - if !handler.authorizeEndpointManagement { - httperror.WriteErrorResponse(w, ErrEndpointManagementDisabled, http.StatusServiceUnavailable, handler.Logger) - return - } - - payload, err := convertPostEndpointRequestToPayload(r) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) - return - } - - endpoint, err := handler.createEndpoint(payload) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - encodeJSON(w, &endpoint, handler.Logger) -} - -// handleGetEndpoint handles GET requests on /endpoints/:id -func (handler *EndpointHandler) handleGetEndpoint(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - id := vars["id"] - - endpointID, err := strconv.Atoi(id) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) - return - } - - endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID)) - if err == portainer.ErrEndpointNotFound { - httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) - return - } else if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - encodeJSON(w, endpoint, handler.Logger) -} - -// handlePutEndpointAccess handles PUT requests on /endpoints/:id/access -func (handler *EndpointHandler) handlePutEndpointAccess(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - id := vars["id"] - - endpointID, err := strconv.Atoi(id) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) - return - } - - var req putEndpointAccessRequest - if err = json.NewDecoder(r.Body).Decode(&req); err != nil { - httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger) - return - } - - _, err = govalidator.ValidateStruct(req) - if err != nil { - httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) - return - } - - endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID)) - if err == portainer.ErrEndpointNotFound { - httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) - return - } else if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - if req.AuthorizedUsers != nil { - authorizedUserIDs := []portainer.UserID{} - for _, value := range req.AuthorizedUsers { - authorizedUserIDs = append(authorizedUserIDs, portainer.UserID(value)) - } - endpoint.AuthorizedUsers = authorizedUserIDs - } - - if req.AuthorizedTeams != nil { - authorizedTeamIDs := []portainer.TeamID{} - for _, value := range req.AuthorizedTeams { - authorizedTeamIDs = append(authorizedTeamIDs, portainer.TeamID(value)) - } - endpoint.AuthorizedTeams = authorizedTeamIDs - } - - err = handler.EndpointService.UpdateEndpoint(endpoint.ID, endpoint) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } -} - -// handlePutEndpoint handles PUT requests on /endpoints/:id -func (handler *EndpointHandler) handlePutEndpoint(w http.ResponseWriter, r *http.Request) { - if !handler.authorizeEndpointManagement { - httperror.WriteErrorResponse(w, ErrEndpointManagementDisabled, http.StatusServiceUnavailable, handler.Logger) - return - } - - vars := mux.Vars(r) - id := vars["id"] - - endpointID, err := strconv.Atoi(id) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) - return - } - - var req putEndpointsRequest - if err = json.NewDecoder(r.Body).Decode(&req); err != nil { - httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger) - return - } - - _, err = govalidator.ValidateStruct(req) - if err != nil { - httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) - return - } - - endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID)) - if err == portainer.ErrEndpointNotFound { - httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) - return - } else if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - if req.Name != "" { - endpoint.Name = req.Name - } - - if req.URL != "" { - endpoint.URL = req.URL - } - - if req.PublicURL != "" { - endpoint.PublicURL = req.PublicURL - } - - if req.GroupID != 0 { - endpoint.GroupID = portainer.EndpointGroupID(req.GroupID) - } - - if endpoint.Type == portainer.AzureEnvironment { - if req.AzureApplicationID != "" { - endpoint.AzureCredentials.ApplicationID = req.AzureApplicationID - } - if req.AzureTenantID != "" { - endpoint.AzureCredentials.TenantID = req.AzureTenantID - } - if req.AzureAuthenticationKey != "" { - endpoint.AzureCredentials.AuthenticationKey = req.AzureAuthenticationKey - } - } - - folder := strconv.Itoa(int(endpoint.ID)) - if req.TLS { - endpoint.TLSConfig.TLS = true - endpoint.TLSConfig.TLSSkipVerify = req.TLSSkipVerify - if !req.TLSSkipVerify { - caCertPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileCA) - endpoint.TLSConfig.TLSCACertPath = caCertPath - } else { - endpoint.TLSConfig.TLSCACertPath = "" - handler.FileService.DeleteTLSFile(folder, portainer.TLSFileCA) - } - - if !req.TLSSkipClientVerify { - certPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileCert) - endpoint.TLSConfig.TLSCertPath = certPath - keyPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileKey) - endpoint.TLSConfig.TLSKeyPath = keyPath - } else { - endpoint.TLSConfig.TLSCertPath = "" - handler.FileService.DeleteTLSFile(folder, portainer.TLSFileCert) - endpoint.TLSConfig.TLSKeyPath = "" - handler.FileService.DeleteTLSFile(folder, portainer.TLSFileKey) - } - } else { - endpoint.TLSConfig.TLS = false - endpoint.TLSConfig.TLSSkipVerify = false - endpoint.TLSConfig.TLSCACertPath = "" - endpoint.TLSConfig.TLSCertPath = "" - endpoint.TLSConfig.TLSKeyPath = "" - err = handler.FileService.DeleteTLSFiles(folder) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - } - - _, err = handler.ProxyManager.CreateAndRegisterProxy(endpoint) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - err = handler.EndpointService.UpdateEndpoint(endpoint.ID, endpoint) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } -} - -// handleDeleteEndpoint handles DELETE requests on /endpoints/:id -func (handler *EndpointHandler) handleDeleteEndpoint(w http.ResponseWriter, r *http.Request) { - if !handler.authorizeEndpointManagement { - httperror.WriteErrorResponse(w, ErrEndpointManagementDisabled, http.StatusServiceUnavailable, handler.Logger) - return - } - - vars := mux.Vars(r) - id := vars["id"] - - endpointID, err := strconv.Atoi(id) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) - return - } - - endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID)) - - if err == portainer.ErrEndpointNotFound { - httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) - return - } else if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - handler.ProxyManager.DeleteProxy(string(endpointID)) - handler.ProxyManager.DeleteExtensionProxies(string(endpointID)) - - err = handler.EndpointService.DeleteEndpoint(portainer.EndpointID(endpointID)) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - if endpoint.TLSConfig.TLS { - err = handler.FileService.DeleteTLSFiles(id) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - } -} diff --git a/api/http/handler/endpoint_group.go b/api/http/handler/endpoint_group.go deleted file mode 100644 index 064f0dff1..000000000 --- a/api/http/handler/endpoint_group.go +++ /dev/null @@ -1,364 +0,0 @@ -package handler - -import ( - "github.com/portainer/portainer" - httperror "github.com/portainer/portainer/http/error" - "github.com/portainer/portainer/http/security" - - "encoding/json" - "log" - "net/http" - "os" - "strconv" - - "github.com/asaskevich/govalidator" - "github.com/gorilla/mux" -) - -// EndpointGroupHandler represents an HTTP API handler for managing endpoint groups. -type EndpointGroupHandler struct { - *mux.Router - Logger *log.Logger - EndpointService portainer.EndpointService - EndpointGroupService portainer.EndpointGroupService -} - -// NewEndpointGroupHandler returns a new instance of EndpointGroupHandler. -func NewEndpointGroupHandler(bouncer *security.RequestBouncer) *EndpointGroupHandler { - h := &EndpointGroupHandler{ - Router: mux.NewRouter(), - Logger: log.New(os.Stderr, "", log.LstdFlags), - } - h.Handle("/endpoint_groups", - bouncer.AdministratorAccess(http.HandlerFunc(h.handlePostEndpointGroups))).Methods(http.MethodPost) - h.Handle("/endpoint_groups", - bouncer.RestrictedAccess(http.HandlerFunc(h.handleGetEndpointGroups))).Methods(http.MethodGet) - h.Handle("/endpoint_groups/{id}", - bouncer.AdministratorAccess(http.HandlerFunc(h.handleGetEndpointGroup))).Methods(http.MethodGet) - h.Handle("/endpoint_groups/{id}", - bouncer.AdministratorAccess(http.HandlerFunc(h.handlePutEndpointGroup))).Methods(http.MethodPut) - h.Handle("/endpoint_groups/{id}/access", - bouncer.AdministratorAccess(http.HandlerFunc(h.handlePutEndpointGroupAccess))).Methods(http.MethodPut) - h.Handle("/endpoint_groups/{id}", - bouncer.AdministratorAccess(http.HandlerFunc(h.handleDeleteEndpointGroup))).Methods(http.MethodDelete) - - return h -} - -type ( - postEndpointGroupsResponse struct { - ID int `json:"Id"` - } - - postEndpointGroupsRequest struct { - Name string `valid:"required"` - Description string `valid:"-"` - Labels []portainer.Pair `valid:""` - AssociatedEndpoints []portainer.EndpointID `valid:""` - } - - putEndpointGroupAccessRequest struct { - AuthorizedUsers []int `valid:"-"` - AuthorizedTeams []int `valid:"-"` - } - - putEndpointGroupsRequest struct { - Name string `valid:"-"` - Description string `valid:"-"` - Labels []portainer.Pair `valid:""` - AssociatedEndpoints []portainer.EndpointID `valid:""` - } -) - -// handleGetEndpointGroups handles GET requests on /endpoint_groups -func (handler *EndpointGroupHandler) handleGetEndpointGroups(w http.ResponseWriter, r *http.Request) { - securityContext, err := security.RetrieveRestrictedRequestContext(r) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - endpointGroups, err := handler.EndpointGroupService.EndpointGroups() - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - filteredEndpointGroups, err := security.FilterEndpointGroups(endpointGroups, securityContext) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - encodeJSON(w, filteredEndpointGroups, handler.Logger) -} - -// handlePostEndpointGroups handles POST requests on /endpoint_groups -func (handler *EndpointGroupHandler) handlePostEndpointGroups(w http.ResponseWriter, r *http.Request) { - var req postEndpointGroupsRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger) - return - } - - _, err := govalidator.ValidateStruct(req) - if err != nil { - httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) - return - } - - endpointGroup := &portainer.EndpointGroup{ - Name: req.Name, - Description: req.Description, - Labels: req.Labels, - AuthorizedUsers: []portainer.UserID{}, - AuthorizedTeams: []portainer.TeamID{}, - } - - err = handler.EndpointGroupService.CreateEndpointGroup(endpointGroup) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - endpoints, err := handler.EndpointService.Endpoints() - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - for _, endpoint := range endpoints { - if endpoint.GroupID == portainer.EndpointGroupID(1) { - err = handler.checkForGroupAssignment(endpoint, endpointGroup.ID, req.AssociatedEndpoints) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - } - } - - encodeJSON(w, &postEndpointGroupsResponse{ID: int(endpointGroup.ID)}, handler.Logger) -} - -// handleGetEndpointGroup handles GET requests on /endpoint_groups/:id -func (handler *EndpointGroupHandler) handleGetEndpointGroup(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - id := vars["id"] - - endpointGroupID, err := strconv.Atoi(id) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) - return - } - - endpointGroup, err := handler.EndpointGroupService.EndpointGroup(portainer.EndpointGroupID(endpointGroupID)) - if err == portainer.ErrEndpointGroupNotFound { - httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) - return - } else if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - encodeJSON(w, endpointGroup, handler.Logger) -} - -// handlePutEndpointGroupAccess handles PUT requests on /endpoint_groups/:id/access -func (handler *EndpointGroupHandler) handlePutEndpointGroupAccess(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - id := vars["id"] - - endpointGroupID, err := strconv.Atoi(id) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) - return - } - - var req putEndpointGroupAccessRequest - if err = json.NewDecoder(r.Body).Decode(&req); err != nil { - httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger) - return - } - - _, err = govalidator.ValidateStruct(req) - if err != nil { - httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) - return - } - - endpointGroup, err := handler.EndpointGroupService.EndpointGroup(portainer.EndpointGroupID(endpointGroupID)) - if err == portainer.ErrEndpointGroupNotFound { - httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) - return - } else if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - if req.AuthorizedUsers != nil { - authorizedUserIDs := []portainer.UserID{} - for _, value := range req.AuthorizedUsers { - authorizedUserIDs = append(authorizedUserIDs, portainer.UserID(value)) - } - endpointGroup.AuthorizedUsers = authorizedUserIDs - } - - if req.AuthorizedTeams != nil { - authorizedTeamIDs := []portainer.TeamID{} - for _, value := range req.AuthorizedTeams { - authorizedTeamIDs = append(authorizedTeamIDs, portainer.TeamID(value)) - } - endpointGroup.AuthorizedTeams = authorizedTeamIDs - } - - err = handler.EndpointGroupService.UpdateEndpointGroup(endpointGroup.ID, endpointGroup) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } -} - -// handlePutEndpointGroup handles PUT requests on /endpoint_groups/:id -func (handler *EndpointGroupHandler) handlePutEndpointGroup(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - id := vars["id"] - - endpointGroupID, err := strconv.Atoi(id) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) - return - } - - var req putEndpointGroupsRequest - if err = json.NewDecoder(r.Body).Decode(&req); err != nil { - httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger) - return - } - - _, err = govalidator.ValidateStruct(req) - if err != nil { - httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) - return - } - - groupID := portainer.EndpointGroupID(endpointGroupID) - endpointGroup, err := handler.EndpointGroupService.EndpointGroup(groupID) - if err == portainer.ErrEndpointGroupNotFound { - httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) - return - } else if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - if req.Name != "" { - endpointGroup.Name = req.Name - } - - if req.Description != "" { - endpointGroup.Description = req.Description - } - - endpointGroup.Labels = req.Labels - - err = handler.EndpointGroupService.UpdateEndpointGroup(endpointGroup.ID, endpointGroup) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - endpoints, err := handler.EndpointService.Endpoints() - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - for _, endpoint := range endpoints { - err = handler.updateEndpointGroup(endpoint, groupID, req.AssociatedEndpoints) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - } -} - -func (handler *EndpointGroupHandler) updateEndpointGroup(endpoint portainer.Endpoint, groupID portainer.EndpointGroupID, associatedEndpoints []portainer.EndpointID) error { - if endpoint.GroupID == groupID { - return handler.checkForGroupUnassignment(endpoint, associatedEndpoints) - } else if endpoint.GroupID == portainer.EndpointGroupID(1) { - return handler.checkForGroupAssignment(endpoint, groupID, associatedEndpoints) - } - return nil -} - -func (handler *EndpointGroupHandler) checkForGroupUnassignment(endpoint portainer.Endpoint, associatedEndpoints []portainer.EndpointID) error { - for _, id := range associatedEndpoints { - if id == endpoint.ID { - return nil - } - } - - endpoint.GroupID = portainer.EndpointGroupID(1) - return handler.EndpointService.UpdateEndpoint(endpoint.ID, &endpoint) -} - -func (handler *EndpointGroupHandler) checkForGroupAssignment(endpoint portainer.Endpoint, groupID portainer.EndpointGroupID, associatedEndpoints []portainer.EndpointID) error { - for _, id := range associatedEndpoints { - - if id == endpoint.ID { - endpoint.GroupID = groupID - return handler.EndpointService.UpdateEndpoint(endpoint.ID, &endpoint) - } - } - return nil -} - -// handleDeleteEndpointGroup handles DELETE requests on /endpoint_groups/:id -func (handler *EndpointGroupHandler) handleDeleteEndpointGroup(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - id := vars["id"] - - endpointGroupID, err := strconv.Atoi(id) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) - return - } - - if endpointGroupID == 1 { - httperror.WriteErrorResponse(w, portainer.ErrCannotRemoveDefaultGroup, http.StatusForbidden, handler.Logger) - return - } - - groupID := portainer.EndpointGroupID(endpointGroupID) - _, err = handler.EndpointGroupService.EndpointGroup(groupID) - if err == portainer.ErrEndpointGroupNotFound { - httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) - return - } else if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - err = handler.EndpointGroupService.DeleteEndpointGroup(portainer.EndpointGroupID(endpointGroupID)) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - endpoints, err := handler.EndpointService.Endpoints() - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - for _, endpoint := range endpoints { - if endpoint.GroupID == groupID { - endpoint.GroupID = portainer.EndpointGroupID(1) - err = handler.EndpointService.UpdateEndpoint(endpoint.ID, &endpoint) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - } - } -} diff --git a/api/http/handler/endpointgroups/endpointgroup_create.go b/api/http/handler/endpointgroups/endpointgroup_create.go new file mode 100644 index 000000000..834122f08 --- /dev/null +++ b/api/http/handler/endpointgroups/endpointgroup_create.go @@ -0,0 +1,63 @@ +package endpointgroups + +import ( + "net/http" + + "github.com/asaskevich/govalidator" + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" +) + +type endpointGroupCreatePayload struct { + Name string + Description string + Labels []portainer.Pair + AssociatedEndpoints []portainer.EndpointID +} + +func (payload *endpointGroupCreatePayload) Validate(r *http.Request) error { + if govalidator.IsNull(payload.Name) { + return portainer.Error("Invalid endpoint group name") + } + return nil +} + +// POST request on /api/endpoint_groups +func (handler *Handler) endpointGroupCreate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + var payload endpointGroupCreatePayload + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + endpointGroup := &portainer.EndpointGroup{ + Name: payload.Name, + Description: payload.Description, + Labels: payload.Labels, + AuthorizedUsers: []portainer.UserID{}, + AuthorizedTeams: []portainer.TeamID{}, + } + + err = handler.EndpointGroupService.CreateEndpointGroup(endpointGroup) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the endpoint group inside the database", err} + } + + endpoints, err := handler.EndpointService.Endpoints() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoints from the database", err} + } + + for _, endpoint := range endpoints { + if endpoint.GroupID == portainer.EndpointGroupID(1) { + err = handler.checkForGroupAssignment(endpoint, endpointGroup.ID, payload.AssociatedEndpoints) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update endpoint", err} + } + } + } + + return response.JSON(w, endpointGroup) +} diff --git a/api/http/handler/endpointgroups/endpointgroup_delete.go b/api/http/handler/endpointgroups/endpointgroup_delete.go new file mode 100644 index 000000000..87c34f94d --- /dev/null +++ b/api/http/handler/endpointgroups/endpointgroup_delete.go @@ -0,0 +1,51 @@ +package endpointgroups + +import ( + "net/http" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" +) + +// DELETE request on /api/endpoint_groups/:id +func (handler *Handler) endpointGroupDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + endpointGroupID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid endpoint group identifier route variable", err} + } + + if endpointGroupID == 1 { + return &httperror.HandlerError{http.StatusForbidden, "Unable to remove the default 'Unassigned' group", portainer.ErrCannotRemoveDefaultGroup} + } + + _, err = handler.EndpointGroupService.EndpointGroup(portainer.EndpointGroupID(endpointGroupID)) + if err == portainer.ErrEndpointGroupNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint group with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint group with the specified identifier inside the database", err} + } + + err = handler.EndpointGroupService.DeleteEndpointGroup(portainer.EndpointGroupID(endpointGroupID)) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove the endpoint group from the database", err} + } + + endpoints, err := handler.EndpointService.Endpoints() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoints from the database", err} + } + + for _, endpoint := range endpoints { + if endpoint.GroupID == portainer.EndpointGroupID(endpointGroupID) { + endpoint.GroupID = portainer.EndpointGroupID(1) + err = handler.EndpointService.UpdateEndpoint(endpoint.ID, &endpoint) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update endpoint", err} + } + } + } + + return response.Empty(w) +} diff --git a/api/http/handler/endpointgroups/endpointgroup_inspect.go b/api/http/handler/endpointgroups/endpointgroup_inspect.go new file mode 100644 index 000000000..8ff26b28f --- /dev/null +++ b/api/http/handler/endpointgroups/endpointgroup_inspect.go @@ -0,0 +1,27 @@ +package endpointgroups + +import ( + "net/http" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" +) + +// GET request on /api/endpoint_groups/:id +func (handler *Handler) endpointGroupInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + endpointGroupID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid endpoint group identifier route variable", err} + } + + endpointGroup, err := handler.EndpointGroupService.EndpointGroup(portainer.EndpointGroupID(endpointGroupID)) + if err == portainer.ErrEndpointGroupNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint group with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint group with the specified identifier inside the database", err} + } + + return response.JSON(w, endpointGroup) +} diff --git a/api/http/handler/endpointgroups/endpointgroup_list.go b/api/http/handler/endpointgroups/endpointgroup_list.go new file mode 100644 index 000000000..fa7a35ec4 --- /dev/null +++ b/api/http/handler/endpointgroups/endpointgroup_list.go @@ -0,0 +1,25 @@ +package endpointgroups + +import ( + "net/http" + + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/response" + "github.com/portainer/portainer/http/security" +) + +// GET request on /api/endpoint_groups +func (handler *Handler) endpointGroupList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + endpointGroups, err := handler.EndpointGroupService.EndpointGroups() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoint groups from the database", err} + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} + } + + endpointGroups = security.FilterEndpointGroups(endpointGroups, securityContext) + return response.JSON(w, endpointGroups) +} diff --git a/api/http/handler/endpointgroups/endpointgroup_update.go b/api/http/handler/endpointgroups/endpointgroup_update.go new file mode 100644 index 000000000..2f23c0f66 --- /dev/null +++ b/api/http/handler/endpointgroups/endpointgroup_update.go @@ -0,0 +1,71 @@ +package endpointgroups + +import ( + "net/http" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" +) + +type endpointGroupUpdatePayload struct { + Name string + Description string + Labels []portainer.Pair + AssociatedEndpoints []portainer.EndpointID +} + +func (payload *endpointGroupUpdatePayload) Validate(r *http.Request) error { + return nil +} + +// PUT request on /api/endpoint_groups/:id +func (handler *Handler) endpointGroupUpdate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + endpointGroupID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid endpoint group identifier route variable", err} + } + + var payload endpointGroupUpdatePayload + err = request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + endpointGroup, err := handler.EndpointGroupService.EndpointGroup(portainer.EndpointGroupID(endpointGroupID)) + if err == portainer.ErrEndpointGroupNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint group with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint group with the specified identifier inside the database", err} + } + + if payload.Name != "" { + endpointGroup.Name = payload.Name + } + + if payload.Description != "" { + endpointGroup.Description = payload.Description + } + + endpointGroup.Labels = payload.Labels + + err = handler.EndpointGroupService.UpdateEndpointGroup(endpointGroup.ID, endpointGroup) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint group changes inside the database", err} + } + + endpoints, err := handler.EndpointService.Endpoints() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoints from the database", err} + } + + for _, endpoint := range endpoints { + err = handler.updateEndpointGroup(endpoint, portainer.EndpointGroupID(endpointGroupID), payload.AssociatedEndpoints) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update endpoint", err} + } + } + + return response.JSON(w, endpointGroup) +} diff --git a/api/http/handler/endpointgroups/endpointgroup_update_access.go b/api/http/handler/endpointgroups/endpointgroup_update_access.go new file mode 100644 index 000000000..a39dbea94 --- /dev/null +++ b/api/http/handler/endpointgroups/endpointgroup_update_access.go @@ -0,0 +1,63 @@ +package endpointgroups + +import ( + "net/http" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" +) + +type endpointGroupUpdateAccessPayload struct { + AuthorizedUsers []int + AuthorizedTeams []int +} + +func (payload *endpointGroupUpdateAccessPayload) Validate(r *http.Request) error { + return nil +} + +// PUT request on /api/endpoint_groups/:id/access +func (handler *Handler) endpointGroupUpdateAccess(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + endpointGroupID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid endpoint group identifier route variable", err} + } + + var payload endpointGroupUpdateAccessPayload + err = request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + endpointGroup, err := handler.EndpointGroupService.EndpointGroup(portainer.EndpointGroupID(endpointGroupID)) + if err == portainer.ErrEndpointGroupNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint group with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint group with the specified identifier inside the database", err} + } + + if payload.AuthorizedUsers != nil { + authorizedUserIDs := []portainer.UserID{} + for _, value := range payload.AuthorizedUsers { + authorizedUserIDs = append(authorizedUserIDs, portainer.UserID(value)) + } + endpointGroup.AuthorizedUsers = authorizedUserIDs + } + + if payload.AuthorizedTeams != nil { + authorizedTeamIDs := []portainer.TeamID{} + for _, value := range payload.AuthorizedTeams { + authorizedTeamIDs = append(authorizedTeamIDs, portainer.TeamID(value)) + } + endpointGroup.AuthorizedTeams = authorizedTeamIDs + } + + err = handler.EndpointGroupService.UpdateEndpointGroup(endpointGroup.ID, endpointGroup) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint group changes inside the database", err} + } + + return response.JSON(w, endpointGroup) +} diff --git a/api/http/handler/endpointgroups/handler.go b/api/http/handler/endpointgroups/handler.go new file mode 100644 index 000000000..f31a40a27 --- /dev/null +++ b/api/http/handler/endpointgroups/handler.go @@ -0,0 +1,69 @@ +package endpointgroups + +import ( + "net/http" + + "github.com/gorilla/mux" + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/security" +) + +// Handler is the HTTP handler used to handle endpoint group operations. +type Handler struct { + *mux.Router + EndpointService portainer.EndpointService + EndpointGroupService portainer.EndpointGroupService +} + +// NewHandler creates a handler to manage endpoint group operations. +func NewHandler(bouncer *security.RequestBouncer) *Handler { + h := &Handler{ + Router: mux.NewRouter(), + } + h.Handle("/endpoint_groups", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.endpointGroupCreate))).Methods(http.MethodPost) + h.Handle("/endpoint_groups", + bouncer.RestrictedAccess(httperror.LoggerHandler(h.endpointGroupList))).Methods(http.MethodGet) + h.Handle("/endpoint_groups/{id}", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.endpointGroupInspect))).Methods(http.MethodGet) + h.Handle("/endpoint_groups/{id}", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.endpointGroupUpdate))).Methods(http.MethodPut) + h.Handle("/endpoint_groups/{id}/access", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.endpointGroupUpdateAccess))).Methods(http.MethodPut) + h.Handle("/endpoint_groups/{id}", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.endpointGroupDelete))).Methods(http.MethodDelete) + + return h +} + +func (handler *Handler) checkForGroupUnassignment(endpoint portainer.Endpoint, associatedEndpoints []portainer.EndpointID) error { + for _, id := range associatedEndpoints { + if id == endpoint.ID { + return nil + } + } + + endpoint.GroupID = portainer.EndpointGroupID(1) + return handler.EndpointService.UpdateEndpoint(endpoint.ID, &endpoint) +} + +func (handler *Handler) checkForGroupAssignment(endpoint portainer.Endpoint, groupID portainer.EndpointGroupID, associatedEndpoints []portainer.EndpointID) error { + for _, id := range associatedEndpoints { + + if id == endpoint.ID { + endpoint.GroupID = groupID + return handler.EndpointService.UpdateEndpoint(endpoint.ID, &endpoint) + } + } + return nil +} + +func (handler *Handler) updateEndpointGroup(endpoint portainer.Endpoint, groupID portainer.EndpointGroupID, associatedEndpoints []portainer.EndpointID) error { + if endpoint.GroupID == groupID { + return handler.checkForGroupUnassignment(endpoint, associatedEndpoints) + } else if endpoint.GroupID == portainer.EndpointGroupID(1) { + return handler.checkForGroupAssignment(endpoint, groupID, associatedEndpoints) + } + return nil +} diff --git a/api/http/handler/endpointproxy/handler.go b/api/http/handler/endpointproxy/handler.go new file mode 100644 index 000000000..a9543f938 --- /dev/null +++ b/api/http/handler/endpointproxy/handler.go @@ -0,0 +1,50 @@ +package endpointproxy + +import ( + "github.com/gorilla/mux" + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/proxy" + "github.com/portainer/portainer/http/security" +) + +// Handler is the HTTP handler used to proxy requests to external APIs. +type Handler struct { + *mux.Router + EndpointService portainer.EndpointService + EndpointGroupService portainer.EndpointGroupService + TeamMembershipService portainer.TeamMembershipService + ProxyManager *proxy.Manager +} + +// NewHandler creates a handler to proxy requests to external APIs. +func NewHandler(bouncer *security.RequestBouncer) *Handler { + h := &Handler{ + Router: mux.NewRouter(), + } + h.PathPrefix("/{id}/azure").Handler( + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.proxyRequestsToAzureAPI))) + h.PathPrefix("/{id}/docker").Handler( + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.proxyRequestsToDockerAPI))) + h.PathPrefix("/{id}/extensions/storidge").Handler( + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.proxyRequestsToStoridgeAPI))) + return h +} + +func (handler *Handler) checkEndpointAccess(endpoint *portainer.Endpoint, userID portainer.UserID) error { + memberships, err := handler.TeamMembershipService.TeamMembershipsByUserID(userID) + if err != nil { + return err + } + + group, err := handler.EndpointGroupService.EndpointGroup(endpoint.GroupID) + if err != nil { + return err + } + + if !security.AuthorizedEndpointAccess(endpoint, group, userID, memberships) { + return portainer.ErrEndpointAccessDenied + } + + return nil +} diff --git a/api/http/handler/endpointproxy/proxy_azure.go b/api/http/handler/endpointproxy/proxy_azure.go new file mode 100644 index 000000000..1cf932393 --- /dev/null +++ b/api/http/handler/endpointproxy/proxy_azure.go @@ -0,0 +1,53 @@ +package endpointproxy + +import ( + "strconv" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/security" + + "net/http" +) + +func (handler *Handler) proxyRequestsToAzureAPI(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid endpoint identifier route variable", err} + } + + endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID)) + if err == portainer.ErrEndpointNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err} + } + + tokenData, err := security.RetrieveTokenData(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve user authentication token", err} + } + + if tokenData.Role != portainer.AdministratorRole { + err = handler.checkEndpointAccess(endpoint, tokenData.ID) + if err != nil && err == portainer.ErrEndpointAccessDenied { + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", portainer.ErrEndpointAccessDenied} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to verify permission to access endpoint", err} + } + } + + var proxy http.Handler + proxy = handler.ProxyManager.GetProxy(string(endpointID)) + if proxy == nil { + proxy, err = handler.ProxyManager.CreateAndRegisterProxy(endpoint) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to create proxy", err} + } + } + + id := strconv.Itoa(endpointID) + http.StripPrefix("/"+id+"/azure", proxy).ServeHTTP(w, r) + return nil +} diff --git a/api/http/handler/endpointproxy/proxy_docker.go b/api/http/handler/endpointproxy/proxy_docker.go new file mode 100644 index 000000000..fd73b5c22 --- /dev/null +++ b/api/http/handler/endpointproxy/proxy_docker.go @@ -0,0 +1,53 @@ +package endpointproxy + +import ( + "strconv" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/security" + + "net/http" +) + +func (handler *Handler) proxyRequestsToDockerAPI(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid endpoint identifier route variable", err} + } + + endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID)) + if err == portainer.ErrEndpointNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err} + } + + tokenData, err := security.RetrieveTokenData(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve user authentication token", err} + } + + if tokenData.Role != portainer.AdministratorRole { + err = handler.checkEndpointAccess(endpoint, tokenData.ID) + if err != nil && err == portainer.ErrEndpointAccessDenied { + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", portainer.ErrEndpointAccessDenied} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to verify permission to access endpoint", err} + } + } + + var proxy http.Handler + proxy = handler.ProxyManager.GetProxy(string(endpointID)) + if proxy == nil { + proxy, err = handler.ProxyManager.CreateAndRegisterProxy(endpoint) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to create proxy", err} + } + } + + id := strconv.Itoa(endpointID) + http.StripPrefix("/"+id+"/docker", proxy).ServeHTTP(w, r) + return nil +} diff --git a/api/http/handler/endpointproxy/proxy_storidge.go b/api/http/handler/endpointproxy/proxy_storidge.go new file mode 100644 index 000000000..8a636205c --- /dev/null +++ b/api/http/handler/endpointproxy/proxy_storidge.go @@ -0,0 +1,66 @@ +package endpointproxy + +import ( + "strconv" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/security" + + "net/http" +) + +func (handler *Handler) proxyRequestsToStoridgeAPI(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid endpoint identifier route variable", err} + } + + endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID)) + if err == portainer.ErrEndpointNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err} + } + + tokenData, err := security.RetrieveTokenData(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve user authentication token", err} + } + + if tokenData.Role != portainer.AdministratorRole { + err = handler.checkEndpointAccess(endpoint, tokenData.ID) + if err != nil && err == portainer.ErrEndpointAccessDenied { + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", portainer.ErrEndpointAccessDenied} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to verify permission to access endpoint", err} + } + } + + var storidgeExtension *portainer.EndpointExtension + for _, extension := range endpoint.Extensions { + if extension.Type == portainer.StoridgeEndpointExtension { + storidgeExtension = &extension + } + } + + if storidgeExtension == nil { + return &httperror.HandlerError{http.StatusServiceUnavailable, "Storidge extension not supported on this endpoint", portainer.ErrEndpointExtensionNotSupported} + } + + proxyExtensionKey := string(endpoint.ID) + "_" + string(portainer.StoridgeEndpointExtension) + + var proxy http.Handler + proxy = handler.ProxyManager.GetExtensionProxy(proxyExtensionKey) + if proxy == nil { + proxy, err = handler.ProxyManager.CreateAndRegisterExtensionProxy(proxyExtensionKey, storidgeExtension.URL) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to create extension proxy", err} + } + } + + id := strconv.Itoa(endpointID) + http.StripPrefix("/"+id+"/extensions/storidge", proxy).ServeHTTP(w, r) + return nil +} diff --git a/api/http/handler/endpoints/endpoint_create.go b/api/http/handler/endpoints/endpoint_create.go new file mode 100644 index 000000000..38d2cfd47 --- /dev/null +++ b/api/http/handler/endpoints/endpoint_create.go @@ -0,0 +1,295 @@ +package endpoints + +import ( + "net/http" + "strconv" + "strings" + + "github.com/portainer/portainer" + "github.com/portainer/portainer/crypto" + "github.com/portainer/portainer/http/client" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" +) + +type endpointCreatePayload struct { + Name string + URL string + EndpointType int + PublicURL string + GroupID int + TLS bool + TLSSkipVerify bool + TLSSkipClientVerify bool + TLSCACertFile []byte + TLSCertFile []byte + TLSKeyFile []byte + AzureApplicationID string + AzureTenantID string + AzureAuthenticationKey string +} + +func (payload *endpointCreatePayload) Validate(r *http.Request) error { + name, err := request.RetrieveMultiPartFormValue(r, "Name", false) + if err != nil { + return portainer.Error("Invalid stack name") + } + payload.Name = name + + endpointType, err := request.RetrieveNumericMultiPartFormValue(r, "EndpointType", false) + if err != nil || endpointType == 0 { + return portainer.Error("Invalid endpoint type value. Value must be one of: 1 (Docker environment), 2 (Agent environment) or 3 (Azure environment)") + } + payload.EndpointType = endpointType + + groupID, _ := request.RetrieveNumericMultiPartFormValue(r, "GroupID", true) + if groupID == 0 { + groupID = 1 + } + payload.GroupID = groupID + + useTLS, _ := request.RetrieveBooleanMultiPartFormValue(r, "TLS", true) + payload.TLS = useTLS + + if payload.TLS { + skipTLSServerVerification, _ := request.RetrieveBooleanMultiPartFormValue(r, "TLSSkipVerify", true) + payload.TLSSkipVerify = skipTLSServerVerification + skipTLSClientVerification, _ := request.RetrieveBooleanMultiPartFormValue(r, "TLSSkipClientVerify", true) + payload.TLSSkipClientVerify = skipTLSClientVerification + + if !payload.TLSSkipVerify { + caCert, err := request.RetrieveMultiPartFormFile(r, "TLSCACertFile") + if err != nil { + return portainer.Error("Invalid CA certificate file. Ensure that the file is uploaded correctly") + } + payload.TLSCACertFile = caCert + } + + if !payload.TLSSkipClientVerify { + cert, err := request.RetrieveMultiPartFormFile(r, "TLSCertFile") + if err != nil { + return portainer.Error("Invalid certificate file. Ensure that the file is uploaded correctly") + } + payload.TLSCertFile = cert + + key, err := request.RetrieveMultiPartFormFile(r, "TLSKeyFile") + if err != nil { + return portainer.Error("Invalid key file. Ensure that the file is uploaded correctly") + } + payload.TLSKeyFile = key + } + } + + switch portainer.EndpointType(payload.EndpointType) { + case portainer.AzureEnvironment: + azureApplicationID, err := request.RetrieveMultiPartFormValue(r, "AzureApplicationID", false) + if err != nil { + return portainer.Error("Invalid Azure application ID") + } + payload.AzureApplicationID = azureApplicationID + + azureTenantID, err := request.RetrieveMultiPartFormValue(r, "AzureTenantID", false) + if err != nil { + return portainer.Error("Invalid Azure tenant ID") + } + payload.AzureTenantID = azureTenantID + + azureAuthenticationKey, err := request.RetrieveMultiPartFormValue(r, "AzureAuthenticationKey", false) + if err != nil { + return portainer.Error("Invalid Azure authentication key") + } + payload.AzureAuthenticationKey = azureAuthenticationKey + default: + url, err := request.RetrieveMultiPartFormValue(r, "URL", false) + if err != nil { + return portainer.Error("Invalid endpoint URL") + } + payload.URL = url + + publicURL, _ := request.RetrieveMultiPartFormValue(r, "PublicURL", true) + payload.PublicURL = publicURL + } + + return nil +} + +// POST request on /api/endpoints +func (handler *Handler) endpointCreate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + if !handler.authorizeEndpointManagement { + return &httperror.HandlerError{http.StatusServiceUnavailable, "Endpoint management is disabled", ErrEndpointManagementDisabled} + } + + payload := &endpointCreatePayload{} + err := payload.Validate(r) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + endpoint, endpointCreationError := handler.createEndpoint(payload) + if endpointCreationError != nil { + return endpointCreationError + } + + return response.JSON(w, endpoint) +} + +func (handler *Handler) createEndpoint(payload *endpointCreatePayload) (*portainer.Endpoint, *httperror.HandlerError) { + if portainer.EndpointType(payload.EndpointType) == portainer.AzureEnvironment { + return handler.createAzureEndpoint(payload) + } + + if payload.TLS { + return handler.createTLSSecuredEndpoint(payload) + } + return handler.createUnsecuredEndpoint(payload) +} + +func (handler *Handler) createAzureEndpoint(payload *endpointCreatePayload) (*portainer.Endpoint, *httperror.HandlerError) { + credentials := portainer.AzureCredentials{ + ApplicationID: payload.AzureApplicationID, + TenantID: payload.AzureTenantID, + AuthenticationKey: payload.AzureAuthenticationKey, + } + + httpClient := client.NewHTTPClient() + _, err := httpClient.ExecuteAzureAuthenticationRequest(&credentials) + if err != nil { + return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to authenticate against Azure", err} + } + + endpoint := &portainer.Endpoint{ + Name: payload.Name, + URL: payload.URL, + Type: portainer.AzureEnvironment, + GroupID: portainer.EndpointGroupID(payload.GroupID), + PublicURL: payload.PublicURL, + AuthorizedUsers: []portainer.UserID{}, + AuthorizedTeams: []portainer.TeamID{}, + Extensions: []portainer.EndpointExtension{}, + AzureCredentials: credentials, + } + + err = handler.EndpointService.CreateEndpoint(endpoint) + if err != nil { + return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint inside the database", err} + } + + return endpoint, nil +} + +func (handler *Handler) createUnsecuredEndpoint(payload *endpointCreatePayload) (*portainer.Endpoint, *httperror.HandlerError) { + endpointType := portainer.DockerEnvironment + + if !strings.HasPrefix(payload.URL, "unix://") { + agentOnDockerEnvironment, err := client.ExecutePingOperation(payload.URL, nil) + if err != nil { + return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to ping Docker environment", err} + } + if agentOnDockerEnvironment { + endpointType = portainer.AgentOnDockerEnvironment + } + } + + endpoint := &portainer.Endpoint{ + Name: payload.Name, + URL: payload.URL, + Type: endpointType, + GroupID: portainer.EndpointGroupID(payload.GroupID), + PublicURL: payload.PublicURL, + TLSConfig: portainer.TLSConfiguration{ + TLS: false, + }, + AuthorizedUsers: []portainer.UserID{}, + AuthorizedTeams: []portainer.TeamID{}, + Extensions: []portainer.EndpointExtension{}, + } + + err := handler.EndpointService.CreateEndpoint(endpoint) + if err != nil { + return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint inside the database", err} + } + + return endpoint, nil +} + +func (handler *Handler) createTLSSecuredEndpoint(payload *endpointCreatePayload) (*portainer.Endpoint, *httperror.HandlerError) { + tlsConfig, err := crypto.CreateTLSConfigurationFromBytes(payload.TLSCACertFile, payload.TLSCertFile, payload.TLSKeyFile, payload.TLSSkipClientVerify, payload.TLSSkipVerify) + if err != nil { + return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to create TLS configuration", err} + } + + agentOnDockerEnvironment, err := client.ExecutePingOperation(payload.URL, tlsConfig) + if err != nil { + return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to ping Docker environment", err} + } + + endpointType := portainer.DockerEnvironment + if agentOnDockerEnvironment { + endpointType = portainer.AgentOnDockerEnvironment + } + + endpoint := &portainer.Endpoint{ + Name: payload.Name, + URL: payload.URL, + Type: endpointType, + GroupID: portainer.EndpointGroupID(payload.GroupID), + PublicURL: payload.PublicURL, + TLSConfig: portainer.TLSConfiguration{ + TLS: payload.TLS, + TLSSkipVerify: payload.TLSSkipVerify, + }, + AuthorizedUsers: []portainer.UserID{}, + AuthorizedTeams: []portainer.TeamID{}, + Extensions: []portainer.EndpointExtension{}, + } + + err = handler.EndpointService.CreateEndpoint(endpoint) + if err != nil { + return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint inside the database", err} + } + + filesystemError := handler.storeTLSFiles(endpoint, payload) + if err != nil { + handler.EndpointService.DeleteEndpoint(endpoint.ID) + return nil, filesystemError + } + + err = handler.EndpointService.UpdateEndpoint(endpoint.ID, endpoint) + if err != nil { + return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint changes inside the database", err} + } + + return endpoint, nil +} + +func (handler *Handler) storeTLSFiles(endpoint *portainer.Endpoint, payload *endpointCreatePayload) *httperror.HandlerError { + folder := strconv.Itoa(int(endpoint.ID)) + + if !payload.TLSSkipVerify { + caCertPath, err := handler.FileService.StoreTLSFileFromBytes(folder, portainer.TLSFileCA, payload.TLSCACertFile) + if err != nil { + handler.EndpointService.DeleteEndpoint(endpoint.ID) + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist TLS CA certificate file on disk", err} + } + endpoint.TLSConfig.TLSCACertPath = caCertPath + } + + if !payload.TLSSkipClientVerify { + certPath, err := handler.FileService.StoreTLSFileFromBytes(folder, portainer.TLSFileCert, payload.TLSCertFile) + if err != nil { + handler.EndpointService.DeleteEndpoint(endpoint.ID) + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist TLS certificate file on disk", err} + } + endpoint.TLSConfig.TLSCertPath = certPath + + keyPath, err := handler.FileService.StoreTLSFileFromBytes(folder, portainer.TLSFileKey, payload.TLSKeyFile) + if err != nil { + handler.EndpointService.DeleteEndpoint(endpoint.ID) + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist TLS key file on disk", err} + } + endpoint.TLSConfig.TLSKeyPath = keyPath + } + + return nil +} diff --git a/api/http/handler/endpoints/endpoint_delete.go b/api/http/handler/endpoints/endpoint_delete.go new file mode 100644 index 000000000..4bcb4f14c --- /dev/null +++ b/api/http/handler/endpoints/endpoint_delete.go @@ -0,0 +1,48 @@ +package endpoints + +import ( + "net/http" + "strconv" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" +) + +// DELETE request on /api/endpoints/:id +func (handler *Handler) endpointDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + if !handler.authorizeEndpointManagement { + return &httperror.HandlerError{http.StatusServiceUnavailable, "Endpoint management is disabled", ErrEndpointManagementDisabled} + } + + endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid endpoint identifier route variable", err} + } + + endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID)) + if err == portainer.ErrEndpointNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err} + } + + if endpoint.TLSConfig.TLS { + folder := strconv.Itoa(endpointID) + err = handler.FileService.DeleteTLSFiles(folder) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove TLS files from disk", err} + } + } + + err = handler.EndpointService.DeleteEndpoint(portainer.EndpointID(endpointID)) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove endpoint from the database", err} + } + + handler.ProxyManager.DeleteProxy(string(endpointID)) + handler.ProxyManager.DeleteExtensionProxies(string(endpointID)) + + return response.Empty(w) +} diff --git a/api/http/handler/endpoints/endpoint_extension_add.go b/api/http/handler/endpoints/endpoint_extension_add.go new file mode 100644 index 000000000..25bf1ed71 --- /dev/null +++ b/api/http/handler/endpoints/endpoint_extension_add.go @@ -0,0 +1,73 @@ +package endpoints + +import ( + "net/http" + + "github.com/asaskevich/govalidator" + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" +) + +type endpointExtensionAddPayload struct { + Type int + URL string +} + +func (payload *endpointExtensionAddPayload) Validate(r *http.Request) error { + if payload.Type != 1 { + return portainer.Error("Invalid type value. Value must be one of: 1 (Storidge)") + } + if govalidator.IsNull(payload.URL) { + return portainer.Error("Invalid URL") + } + return nil +} + +// POST request on /api/endpoints/:id/extensions +func (handler *Handler) endpointExtensionAdd(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid endpoint identifier route variable", err} + } + + endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID)) + if err == portainer.ErrEndpointNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err} + } + + var payload endpointExtensionAddPayload + err = request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + extensionType := portainer.EndpointExtensionType(payload.Type) + + var extension *portainer.EndpointExtension + for _, ext := range endpoint.Extensions { + if ext.Type == extensionType { + extension = &ext + } + } + + if extension != nil { + extension.URL = payload.URL + } else { + extension = &portainer.EndpointExtension{ + Type: extensionType, + URL: payload.URL, + } + endpoint.Extensions = append(endpoint.Extensions, *extension) + } + + err = handler.EndpointService.UpdateEndpoint(endpoint.ID, endpoint) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint changes inside the database", err} + } + + return response.JSON(w, extension) +} diff --git a/api/http/handler/endpoints/endpoint_extension_remove.go b/api/http/handler/endpoints/endpoint_extension_remove.go new file mode 100644 index 000000000..bce4fc478 --- /dev/null +++ b/api/http/handler/endpoints/endpoint_extension_remove.go @@ -0,0 +1,43 @@ +package endpoints + +import ( + "net/http" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" +) + +// DELETE request on /api/endpoints/:id/extensions/:extensionType +func (handler *Handler) endpointExtensionRemove(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid endpoint identifier route variable", err} + } + + endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID)) + if err == portainer.ErrEndpointNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err} + } + + extensionType, err := request.RetrieveNumericRouteVariableValue(r, "extensionType") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid extension type route variable", err} + } + + for idx, ext := range endpoint.Extensions { + if ext.Type == portainer.EndpointExtensionType(extensionType) { + endpoint.Extensions = append(endpoint.Extensions[:idx], endpoint.Extensions[idx+1:]...) + } + } + + err = handler.EndpointService.UpdateEndpoint(endpoint.ID, endpoint) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint changes inside the database", err} + } + + return response.Empty(w) +} diff --git a/api/http/handler/endpoints/endpoint_inspect.go b/api/http/handler/endpoints/endpoint_inspect.go new file mode 100644 index 000000000..80b5703a9 --- /dev/null +++ b/api/http/handler/endpoints/endpoint_inspect.go @@ -0,0 +1,27 @@ +package endpoints + +import ( + "net/http" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" +) + +// GET request on /api/endpoints/:id +func (handler *Handler) endpointInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid endpoint identifier route variable", err} + } + + endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID)) + if err == portainer.ErrEndpointNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err} + } + + return response.JSON(w, endpoint) +} diff --git a/api/http/handler/endpoints/endpoint_list.go b/api/http/handler/endpoints/endpoint_list.go new file mode 100644 index 000000000..13268b48d --- /dev/null +++ b/api/http/handler/endpoints/endpoint_list.go @@ -0,0 +1,34 @@ +package endpoints + +import ( + "net/http" + + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/response" + "github.com/portainer/portainer/http/security" +) + +// GET request on /api/endpoints +func (handler *Handler) endpointList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + endpoints, err := handler.EndpointService.Endpoints() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoints from the database", err} + } + + endpointGroups, err := handler.EndpointGroupService.EndpointGroups() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoint groups from the database", err} + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} + } + + filteredEndpoints := security.FilterEndpoints(endpoints, endpointGroups, securityContext) + + for _, endpoint := range filteredEndpoints { + hideFields(&endpoint) + } + return response.JSON(w, filteredEndpoints) +} diff --git a/api/http/handler/endpoints/endpoint_update.go b/api/http/handler/endpoints/endpoint_update.go new file mode 100644 index 000000000..a65e9872b --- /dev/null +++ b/api/http/handler/endpoints/endpoint_update.go @@ -0,0 +1,137 @@ +package endpoints + +import ( + "net/http" + "strconv" + + "github.com/portainer/portainer" + "github.com/portainer/portainer/http/client" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" +) + +type endpointUpdatePayload struct { + Name string + URL string + PublicURL string + GroupID int + TLS bool + TLSSkipVerify bool + TLSSkipClientVerify bool + AzureApplicationID string + AzureTenantID string + AzureAuthenticationKey string +} + +func (payload *endpointUpdatePayload) Validate(r *http.Request) error { + return nil +} + +// PUT request on /api/endpoints/:id +func (handler *Handler) endpointUpdate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + if !handler.authorizeEndpointManagement { + return &httperror.HandlerError{http.StatusServiceUnavailable, "Endpoint management is disabled", ErrEndpointManagementDisabled} + } + + endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid endpoint identifier route variable", err} + } + + var payload endpointUpdatePayload + err = request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID)) + if err == portainer.ErrEndpointNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err} + } + + if payload.Name != "" { + endpoint.Name = payload.Name + } + + if payload.URL != "" { + endpoint.URL = payload.URL + } + + if payload.PublicURL != "" { + endpoint.PublicURL = payload.PublicURL + } + + if payload.GroupID != 0 { + endpoint.GroupID = portainer.EndpointGroupID(payload.GroupID) + } + + if endpoint.Type == portainer.AzureEnvironment { + credentials := endpoint.AzureCredentials + if payload.AzureApplicationID != "" { + credentials.ApplicationID = payload.AzureApplicationID + } + if payload.AzureTenantID != "" { + credentials.TenantID = payload.AzureTenantID + } + if payload.AzureAuthenticationKey != "" { + credentials.AuthenticationKey = payload.AzureAuthenticationKey + } + + httpClient := client.NewHTTPClient() + _, authErr := httpClient.ExecuteAzureAuthenticationRequest(&credentials) + if authErr != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to authenticate against Azure", authErr} + } + endpoint.AzureCredentials = credentials + } + + folder := strconv.Itoa(endpointID) + if payload.TLS { + endpoint.TLSConfig.TLS = true + endpoint.TLSConfig.TLSSkipVerify = payload.TLSSkipVerify + if !payload.TLSSkipVerify { + caCertPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileCA) + endpoint.TLSConfig.TLSCACertPath = caCertPath + } else { + endpoint.TLSConfig.TLSCACertPath = "" + handler.FileService.DeleteTLSFile(folder, portainer.TLSFileCA) + } + + if !payload.TLSSkipClientVerify { + certPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileCert) + endpoint.TLSConfig.TLSCertPath = certPath + keyPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileKey) + endpoint.TLSConfig.TLSKeyPath = keyPath + } else { + endpoint.TLSConfig.TLSCertPath = "" + handler.FileService.DeleteTLSFile(folder, portainer.TLSFileCert) + endpoint.TLSConfig.TLSKeyPath = "" + handler.FileService.DeleteTLSFile(folder, portainer.TLSFileKey) + } + } else { + endpoint.TLSConfig.TLS = false + endpoint.TLSConfig.TLSSkipVerify = false + endpoint.TLSConfig.TLSCACertPath = "" + endpoint.TLSConfig.TLSCertPath = "" + endpoint.TLSConfig.TLSKeyPath = "" + err = handler.FileService.DeleteTLSFiles(folder) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove TLS files from disk", err} + } + } + + _, err = handler.ProxyManager.CreateAndRegisterProxy(endpoint) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to register HTTP proxy for the endpoint", err} + } + + err = handler.EndpointService.UpdateEndpoint(endpoint.ID, endpoint) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint changes inside the database", err} + } + + return response.JSON(w, endpoint) +} diff --git a/api/http/handler/endpoints/endpoint_update_access.go b/api/http/handler/endpoints/endpoint_update_access.go new file mode 100644 index 000000000..6fb72ba5f --- /dev/null +++ b/api/http/handler/endpoints/endpoint_update_access.go @@ -0,0 +1,67 @@ +package endpoints + +import ( + "net/http" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" +) + +type endpointUpdateAccessPayload struct { + AuthorizedUsers []int + AuthorizedTeams []int +} + +func (payload *endpointUpdateAccessPayload) Validate(r *http.Request) error { + return nil +} + +// PUT request on /api/endpoints/:id/access +func (handler *Handler) endpointUpdateAccess(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + if !handler.authorizeEndpointManagement { + return &httperror.HandlerError{http.StatusServiceUnavailable, "Endpoint management is disabled", ErrEndpointManagementDisabled} + } + + endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid endpoint identifier route variable", err} + } + + var payload endpointUpdateAccessPayload + err = request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID)) + if err == portainer.ErrEndpointNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err} + } + + if payload.AuthorizedUsers != nil { + authorizedUserIDs := []portainer.UserID{} + for _, value := range payload.AuthorizedUsers { + authorizedUserIDs = append(authorizedUserIDs, portainer.UserID(value)) + } + endpoint.AuthorizedUsers = authorizedUserIDs + } + + if payload.AuthorizedTeams != nil { + authorizedTeamIDs := []portainer.TeamID{} + for _, value := range payload.AuthorizedTeams { + authorizedTeamIDs = append(authorizedTeamIDs, portainer.TeamID(value)) + } + endpoint.AuthorizedTeams = authorizedTeamIDs + } + + err = handler.EndpointService.UpdateEndpoint(endpoint.ID, endpoint) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint changes inside the database", err} + } + + return response.JSON(w, endpoint) +} diff --git a/api/http/handler/endpoints/handler.go b/api/http/handler/endpoints/handler.go new file mode 100644 index 000000000..6a593479c --- /dev/null +++ b/api/http/handler/endpoints/handler.go @@ -0,0 +1,59 @@ +package endpoints + +import ( + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/proxy" + "github.com/portainer/portainer/http/security" + + "net/http" + + "github.com/gorilla/mux" +) + +const ( + // ErrEndpointManagementDisabled is an error raised when trying to access the endpoints management endpoints + // when the server has been started with the --external-endpoints flag + ErrEndpointManagementDisabled = portainer.Error("Endpoint management is disabled") +) + +func hideFields(endpoint *portainer.Endpoint) { + endpoint.AzureCredentials = portainer.AzureCredentials{} +} + +// Handler is the HTTP handler used to handle endpoint operations. +type Handler struct { + *mux.Router + authorizeEndpointManagement bool + EndpointService portainer.EndpointService + EndpointGroupService portainer.EndpointGroupService + FileService portainer.FileService + ProxyManager *proxy.Manager +} + +// NewHandler creates a handler to manage endpoint operations. +func NewHandler(bouncer *security.RequestBouncer, authorizeEndpointManagement bool) *Handler { + h := &Handler{ + Router: mux.NewRouter(), + authorizeEndpointManagement: authorizeEndpointManagement, + } + + h.Handle("/endpoints", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.endpointCreate))).Methods(http.MethodPost) + h.Handle("/endpoints", + bouncer.RestrictedAccess(httperror.LoggerHandler(h.endpointList))).Methods(http.MethodGet) + h.Handle("/endpoints/{id}", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.endpointInspect))).Methods(http.MethodGet) + h.Handle("/endpoints/{id}", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.endpointUpdate))).Methods(http.MethodPut) + h.Handle("/endpoints/{id}/access", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.endpointUpdateAccess))).Methods(http.MethodPut) + h.Handle("/endpoints/{id}", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.endpointDelete))).Methods(http.MethodDelete) + h.Handle("/endpoints/{id}/extensions", + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.endpointExtensionAdd))).Methods(http.MethodPost) + h.Handle("/endpoints/{id}/extensions/{extensionType}", + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.endpointExtensionRemove))).Methods(http.MethodDelete) + + return h +} diff --git a/api/http/handler/extensions.go b/api/http/handler/extensions.go deleted file mode 100644 index ecae4678e..000000000 --- a/api/http/handler/extensions.go +++ /dev/null @@ -1,143 +0,0 @@ -package handler - -import ( - "encoding/json" - "strconv" - - "github.com/asaskevich/govalidator" - "github.com/portainer/portainer" - httperror "github.com/portainer/portainer/http/error" - "github.com/portainer/portainer/http/proxy" - "github.com/portainer/portainer/http/security" - - "log" - "net/http" - "os" - - "github.com/gorilla/mux" -) - -// ExtensionHandler represents an HTTP API handler for managing Settings. -type ExtensionHandler struct { - *mux.Router - Logger *log.Logger - EndpointService portainer.EndpointService - ProxyManager *proxy.Manager -} - -// NewExtensionHandler returns a new instance of ExtensionHandler. -func NewExtensionHandler(bouncer *security.RequestBouncer) *ExtensionHandler { - h := &ExtensionHandler{ - Router: mux.NewRouter(), - Logger: log.New(os.Stderr, "", log.LstdFlags), - } - h.Handle("/{endpointId}/extensions", - bouncer.AuthenticatedAccess(http.HandlerFunc(h.handlePostExtensions))).Methods(http.MethodPost) - h.Handle("/{endpointId}/extensions/{extensionType}", - bouncer.AuthenticatedAccess(http.HandlerFunc(h.handleDeleteExtensions))).Methods(http.MethodDelete) - return h -} - -type ( - postExtensionRequest struct { - Type int `valid:"required"` - URL string `valid:"required"` - } -) - -func (handler *ExtensionHandler) handlePostExtensions(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - id, err := strconv.Atoi(vars["endpointId"]) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) - return - } - endpointID := portainer.EndpointID(id) - - endpoint, err := handler.EndpointService.Endpoint(endpointID) - if err == portainer.ErrEndpointNotFound { - httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) - return - } else if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - var req postExtensionRequest - if err = json.NewDecoder(r.Body).Decode(&req); err != nil { - httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger) - return - } - - _, err = govalidator.ValidateStruct(req) - if err != nil { - httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) - return - } - - extensionType := portainer.EndpointExtensionType(req.Type) - - var extension *portainer.EndpointExtension - - for _, ext := range endpoint.Extensions { - if ext.Type == extensionType { - extension = &ext - } - } - - if extension != nil { - extension.URL = req.URL - } else { - extension = &portainer.EndpointExtension{ - Type: extensionType, - URL: req.URL, - } - endpoint.Extensions = append(endpoint.Extensions, *extension) - } - - err = handler.EndpointService.UpdateEndpoint(endpoint.ID, endpoint) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - encodeJSON(w, extension, handler.Logger) -} - -func (handler *ExtensionHandler) handleDeleteExtensions(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - id, err := strconv.Atoi(vars["endpointId"]) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) - return - } - endpointID := portainer.EndpointID(id) - - endpoint, err := handler.EndpointService.Endpoint(endpointID) - if err == portainer.ErrEndpointNotFound { - httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) - return - } else if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - extType, err := strconv.Atoi(vars["extensionType"]) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) - return - } - extensionType := portainer.EndpointExtensionType(extType) - - for idx, ext := range endpoint.Extensions { - if ext.Type == extensionType { - endpoint.Extensions = append(endpoint.Extensions[:idx], endpoint.Extensions[idx+1:]...) - } - } - - err = handler.EndpointService.UpdateEndpoint(endpoint.ID, endpoint) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } -} diff --git a/api/http/handler/extensions/storidge.go b/api/http/handler/extensions/storidge.go deleted file mode 100644 index bee1cd7b3..000000000 --- a/api/http/handler/extensions/storidge.go +++ /dev/null @@ -1,106 +0,0 @@ -package extensions - -import ( - "strconv" - - "github.com/portainer/portainer" - httperror "github.com/portainer/portainer/http/error" - "github.com/portainer/portainer/http/proxy" - "github.com/portainer/portainer/http/security" - - "log" - "net/http" - "os" - - "github.com/gorilla/mux" -) - -// StoridgeHandler represents an HTTP API handler for proxying requests to the Docker API. -type StoridgeHandler struct { - *mux.Router - Logger *log.Logger - EndpointService portainer.EndpointService - EndpointGroupService portainer.EndpointGroupService - TeamMembershipService portainer.TeamMembershipService - ProxyManager *proxy.Manager -} - -// NewStoridgeHandler returns a new instance of StoridgeHandler. -func NewStoridgeHandler(bouncer *security.RequestBouncer) *StoridgeHandler { - h := &StoridgeHandler{ - Router: mux.NewRouter(), - Logger: log.New(os.Stderr, "", log.LstdFlags), - } - h.PathPrefix("/{id}/extensions/storidge").Handler( - bouncer.AuthenticatedAccess(http.HandlerFunc(h.proxyRequestsToStoridgeAPI))) - return h -} - -func (handler *StoridgeHandler) proxyRequestsToStoridgeAPI(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - id := vars["id"] - - parsedID, err := strconv.Atoi(id) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) - return - } - - endpointID := portainer.EndpointID(parsedID) - endpoint, err := handler.EndpointService.Endpoint(endpointID) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - tokenData, err := security.RetrieveTokenData(r) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - memberships, err := handler.TeamMembershipService.TeamMembershipsByUserID(tokenData.ID) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - if tokenData.Role != portainer.AdministratorRole { - group, err := handler.EndpointGroupService.EndpointGroup(endpoint.GroupID) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - if !security.AuthorizedEndpointAccess(endpoint, group, tokenData.ID, memberships) { - httperror.WriteErrorResponse(w, portainer.ErrEndpointAccessDenied, http.StatusForbidden, handler.Logger) - return - } - } - - var storidgeExtension *portainer.EndpointExtension - for _, extension := range endpoint.Extensions { - if extension.Type == portainer.StoridgeEndpointExtension { - storidgeExtension = &extension - } - } - - if storidgeExtension == nil { - httperror.WriteErrorResponse(w, portainer.ErrEndpointExtensionNotSupported, http.StatusInternalServerError, handler.Logger) - return - } - - proxyExtensionKey := string(endpoint.ID) + "_" + string(portainer.StoridgeEndpointExtension) - - var proxy http.Handler - proxy = handler.ProxyManager.GetExtensionProxy(proxyExtensionKey) - if proxy == nil { - proxy, err = handler.ProxyManager.CreateAndRegisterExtensionProxy(proxyExtensionKey, storidgeExtension.URL) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - } - - http.StripPrefix("/"+id+"/extensions/storidge", proxy).ServeHTTP(w, r) -} diff --git a/api/http/handler/file.go b/api/http/handler/file/handler.go similarity index 54% rename from api/http/handler/file.go rename to api/http/handler/file/handler.go index efc7e2b77..15ec1417f 100644 --- a/api/http/handler/file.go +++ b/api/http/handler/file/handler.go @@ -1,24 +1,19 @@ -package handler +package file import ( - "os" - - "log" "net/http" "strings" ) -// FileHandler represents an HTTP API handler for managing static files. -type FileHandler struct { +// Handler represents an HTTP API handler for managing static files. +type Handler struct { http.Handler - Logger *log.Logger } -// NewFileHandler returns a new instance of FileHandler. -func NewFileHandler(assetPublicPath string) *FileHandler { - h := &FileHandler{ +// NewHandler creates a handler to serve static files. +func NewHandler(assetPublicPath string) *Handler { + h := &Handler{ Handler: http.FileServer(http.Dir(assetPublicPath)), - Logger: log.New(os.Stderr, "", log.LstdFlags), } return h } @@ -32,7 +27,7 @@ func isHTML(acceptContent []string) bool { return false } -func (handler *FileHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { +func (handler *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { if !isHTML(r.Header["Accept"]) { w.Header().Set("Cache-Control", "max-age=31536000") } else { diff --git a/api/http/handler/handler.go b/api/http/handler/handler.go index 70e8f4e8b..84d01ddc5 100644 --- a/api/http/handler/handler.go +++ b/api/http/handler/handler.go @@ -1,53 +1,56 @@ package handler import ( - "encoding/json" - "io/ioutil" - "log" "net/http" "strings" - "github.com/portainer/portainer" - httperror "github.com/portainer/portainer/http/error" - "github.com/portainer/portainer/http/handler/extensions" + "github.com/portainer/portainer/http/handler/auth" + "github.com/portainer/portainer/http/handler/dockerhub" + "github.com/portainer/portainer/http/handler/endpointgroups" + "github.com/portainer/portainer/http/handler/endpointproxy" + "github.com/portainer/portainer/http/handler/endpoints" + "github.com/portainer/portainer/http/handler/file" + "github.com/portainer/portainer/http/handler/registries" + "github.com/portainer/portainer/http/handler/resourcecontrols" + "github.com/portainer/portainer/http/handler/settings" + "github.com/portainer/portainer/http/handler/stacks" + "github.com/portainer/portainer/http/handler/status" + "github.com/portainer/portainer/http/handler/teammemberships" + "github.com/portainer/portainer/http/handler/teams" + "github.com/portainer/portainer/http/handler/templates" + "github.com/portainer/portainer/http/handler/upload" + "github.com/portainer/portainer/http/handler/users" + "github.com/portainer/portainer/http/handler/websocket" ) // Handler is a collection of all the service handlers. type Handler struct { - AuthHandler *AuthHandler - UserHandler *UserHandler - TeamHandler *TeamHandler - TeamMembershipHandler *TeamMembershipHandler - EndpointHandler *EndpointHandler - EndpointGroupHandler *EndpointGroupHandler - RegistryHandler *RegistryHandler - DockerHubHandler *DockerHubHandler - ExtensionHandler *ExtensionHandler - StoridgeHandler *extensions.StoridgeHandler - ResourceHandler *ResourceHandler - StackHandler *StackHandler - StatusHandler *StatusHandler - SettingsHandler *SettingsHandler - TemplatesHandler *TemplatesHandler - DockerHandler *DockerHandler - AzureHandler *AzureHandler - WebSocketHandler *WebSocketHandler - UploadHandler *UploadHandler - FileHandler *FileHandler -} + AuthHandler *auth.Handler -const ( - // ErrInvalidJSON defines an error raised the app is unable to parse request data - ErrInvalidJSON = portainer.Error("Invalid JSON") - // ErrInvalidRequestFormat defines an error raised when the format of the data sent in a request is not valid - ErrInvalidRequestFormat = portainer.Error("Invalid request data format") - // ErrInvalidQueryFormat defines an error raised when the data sent in the query or the URL is invalid - ErrInvalidQueryFormat = portainer.Error("Invalid query format") -) + DockerHubHandler *dockerhub.Handler + EndpointGroupHandler *endpointgroups.Handler + EndpointHandler *endpoints.Handler + EndpointProxyHandler *endpointproxy.Handler + FileHandler *file.Handler + RegistryHandler *registries.Handler + ResourceControlHandler *resourcecontrols.Handler + SettingsHandler *settings.Handler + StackHandler *stacks.Handler + StatusHandler *status.Handler + TeamMembershipHandler *teammemberships.Handler + TeamHandler *teams.Handler + TemplatesHandler *templates.Handler + UploadHandler *upload.Handler + UserHandler *users.Handler + WebSocketHandler *websocket.Handler + + // StoridgeHandler *extensions.StoridgeHandler + // AzureHandler *azure.Handler + // DockerHandler *docker.Handler +} // ServeHTTP delegates a request to the appropriate subhandler. func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - switch { case strings.HasPrefix(r.URL.Path, "/api/auth"): http.StripPrefix("/api", h.AuthHandler).ServeHTTP(w, r) @@ -58,24 +61,22 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { case strings.HasPrefix(r.URL.Path, "/api/endpoints"): switch { case strings.Contains(r.URL.Path, "/docker/"): - http.StripPrefix("/api/endpoints", h.DockerHandler).ServeHTTP(w, r) - case strings.Contains(r.URL.Path, "/stacks"): - http.StripPrefix("/api/endpoints", h.StackHandler).ServeHTTP(w, r) + http.StripPrefix("/api/endpoints", h.EndpointProxyHandler).ServeHTTP(w, r) case strings.Contains(r.URL.Path, "/extensions/storidge"): - http.StripPrefix("/api/endpoints", h.StoridgeHandler).ServeHTTP(w, r) - case strings.Contains(r.URL.Path, "/extensions"): - http.StripPrefix("/api/endpoints", h.ExtensionHandler).ServeHTTP(w, r) + http.StripPrefix("/api/endpoints", h.EndpointProxyHandler).ServeHTTP(w, r) case strings.Contains(r.URL.Path, "/azure/"): - http.StripPrefix("/api/endpoints", h.AzureHandler).ServeHTTP(w, r) + http.StripPrefix("/api/endpoints", h.EndpointProxyHandler).ServeHTTP(w, r) default: http.StripPrefix("/api", h.EndpointHandler).ServeHTTP(w, r) } case strings.HasPrefix(r.URL.Path, "/api/registries"): http.StripPrefix("/api", h.RegistryHandler).ServeHTTP(w, r) case strings.HasPrefix(r.URL.Path, "/api/resource_controls"): - http.StripPrefix("/api", h.ResourceHandler).ServeHTTP(w, r) + http.StripPrefix("/api", h.ResourceControlHandler).ServeHTTP(w, r) case strings.HasPrefix(r.URL.Path, "/api/settings"): http.StripPrefix("/api", h.SettingsHandler).ServeHTTP(w, r) + case strings.HasPrefix(r.URL.Path, "/api/stacks"): + http.StripPrefix("/api", h.StackHandler).ServeHTTP(w, r) case strings.HasPrefix(r.URL.Path, "/api/status"): http.StripPrefix("/api", h.StatusHandler).ServeHTTP(w, r) case strings.HasPrefix(r.URL.Path, "/api/templates"): @@ -94,27 +95,3 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.FileHandler.ServeHTTP(w, r) } } - -// encodeJSON encodes v to w in JSON format. WriteErrorResponse() is called if encoding fails. -func encodeJSON(w http.ResponseWriter, v interface{}, logger *log.Logger) { - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(v); err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, logger) - } -} - -// getUploadedFileContent retrieve the content of a file uploaded in the request. -// Uses requestParameter as the key to retrieve the file in the request payload. -func getUploadedFileContent(request *http.Request, requestParameter string) ([]byte, error) { - file, _, err := request.FormFile(requestParameter) - if err != nil { - return nil, err - } - defer file.Close() - - fileContent, err := ioutil.ReadAll(file) - if err != nil { - return nil, err - } - return fileContent, nil -} diff --git a/api/http/handler/registries/handler.go b/api/http/handler/registries/handler.go new file mode 100644 index 000000000..7a21561db --- /dev/null +++ b/api/http/handler/registries/handler.go @@ -0,0 +1,43 @@ +package registries + +import ( + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/security" + + "net/http" + + "github.com/gorilla/mux" +) + +func hideFields(registry *portainer.Registry) { + registry.Password = "" +} + +// Handler is the HTTP handler used to handle registry operations. +type Handler struct { + *mux.Router + RegistryService portainer.RegistryService +} + +// NewHandler creates a handler to manage registry operations. +func NewHandler(bouncer *security.RequestBouncer) *Handler { + h := &Handler{ + Router: mux.NewRouter(), + } + + h.Handle("/registries", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.registryCreate))).Methods(http.MethodPost) + h.Handle("/registries", + bouncer.RestrictedAccess(httperror.LoggerHandler(h.registryList))).Methods(http.MethodGet) + h.Handle("/registries/{id}", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.registryInspect))).Methods(http.MethodGet) + h.Handle("/registries/{id}", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.registryUpdate))).Methods(http.MethodPut) + h.Handle("/registries/{id}/access", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.registryUpdateAccess))).Methods(http.MethodPut) + h.Handle("/registries/{id}", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.registryDelete))).Methods(http.MethodDelete) + + return h +} diff --git a/api/http/handler/registries/registry_create.go b/api/http/handler/registries/registry_create.go new file mode 100644 index 000000000..781f172bf --- /dev/null +++ b/api/http/handler/registries/registry_create.go @@ -0,0 +1,68 @@ +package registries + +import ( + "net/http" + + "github.com/asaskevich/govalidator" + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" +) + +type registryCreatePayload struct { + Name string + URL string + Authentication bool + Username string + Password string +} + +func (payload *registryCreatePayload) Validate(r *http.Request) error { + if govalidator.IsNull(payload.Name) { + return portainer.Error("Invalid registry name") + } + if govalidator.IsNull(payload.URL) { + return portainer.Error("Invalid registry URL") + } + if payload.Authentication && (govalidator.IsNull(payload.Username) || govalidator.IsNull(payload.Password)) { + return portainer.Error("Invalid credentials. Username and password must be specified when authentication is enabled") + } + return nil +} + +func (handler *Handler) registryCreate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + var payload registryCreatePayload + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + registries, err := handler.RegistryService.Registries() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve registries from the database", err} + } + for _, r := range registries { + if r.URL == payload.URL { + return &httperror.HandlerError{http.StatusConflict, "A registry with the same URL already exists", portainer.ErrRegistryAlreadyExists} + } + } + + registry := &portainer.Registry{ + Name: payload.Name, + URL: payload.URL, + Authentication: payload.Authentication, + Username: payload.Username, + Password: payload.Password, + AuthorizedUsers: []portainer.UserID{}, + AuthorizedTeams: []portainer.TeamID{}, + } + + err = handler.RegistryService.CreateRegistry(registry) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the registry inside the database", err} + } + + hideFields(registry) + return response.JSON(w, registry) +} diff --git a/api/http/handler/registries/registry_delete.go b/api/http/handler/registries/registry_delete.go new file mode 100644 index 000000000..1fbe5bc04 --- /dev/null +++ b/api/http/handler/registries/registry_delete.go @@ -0,0 +1,32 @@ +package registries + +import ( + "net/http" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" +) + +// DELETE request on /api/registries/:id +func (handler *Handler) registryDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + registryID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid registry identifier route variable", err} + } + + _, err = handler.RegistryService.Registry(portainer.RegistryID(registryID)) + if err == portainer.ErrEndpointGroupNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find a registry with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a registry with the specified identifier inside the database", err} + } + + err = handler.RegistryService.DeleteRegistry(portainer.RegistryID(registryID)) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove the registry from the database", err} + } + + return response.Empty(w) +} diff --git a/api/http/handler/registries/registry_inspect.go b/api/http/handler/registries/registry_inspect.go new file mode 100644 index 000000000..d78d1969b --- /dev/null +++ b/api/http/handler/registries/registry_inspect.go @@ -0,0 +1,28 @@ +package registries + +import ( + "net/http" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" +) + +// GET request on /api/registries/:id +func (handler *Handler) registryInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + registryID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid registry identifier route variable", err} + } + + registry, err := handler.RegistryService.Registry(portainer.RegistryID(registryID)) + if err == portainer.ErrEndpointGroupNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find a registry with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a registry with the specified identifier inside the database", err} + } + + hideFields(registry) + return response.JSON(w, registry) +} diff --git a/api/http/handler/registries/registry_list.go b/api/http/handler/registries/registry_list.go new file mode 100644 index 000000000..158f8e3fe --- /dev/null +++ b/api/http/handler/registries/registry_list.go @@ -0,0 +1,29 @@ +package registries + +import ( + "net/http" + + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/response" + "github.com/portainer/portainer/http/security" +) + +// GET request on /api/registries +func (handler *Handler) registryList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + registries, err := handler.RegistryService.Registries() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve registries from the database", err} + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} + } + + filteredRegistries := security.FilterRegistries(registries, securityContext) + + for _, registry := range filteredRegistries { + hideFields(®istry) + } + return response.JSON(w, registries) +} diff --git a/api/http/handler/registries/registry_update.go b/api/http/handler/registries/registry_update.go new file mode 100644 index 000000000..b1a0167bb --- /dev/null +++ b/api/http/handler/registries/registry_update.go @@ -0,0 +1,82 @@ +package registries + +import ( + "net/http" + + "github.com/asaskevich/govalidator" + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" +) + +type registryUpdatePayload struct { + Name string + URL string + Authentication bool + Username string + Password string +} + +func (payload *registryUpdatePayload) Validate(r *http.Request) error { + if payload.Authentication && (govalidator.IsNull(payload.Username) || govalidator.IsNull(payload.Password)) { + return portainer.Error("Invalid credentials. Username and password must be specified when authentication is enabled") + } + return nil +} + +// PUT request on /api/registries/:id +func (handler *Handler) registryUpdate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + registryID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid registry identifier route variable", err} + } + + var payload registryUpdatePayload + err = request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + registry, err := handler.RegistryService.Registry(portainer.RegistryID(registryID)) + if err == portainer.ErrEndpointGroupNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find a registry with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a registry with the specified identifier inside the database", err} + } + + registries, err := handler.RegistryService.Registries() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve registries from the database", err} + } + for _, r := range registries { + if r.URL == payload.URL && r.ID != registry.ID { + return &httperror.HandlerError{http.StatusConflict, "Another registry with the same URL already exists", portainer.ErrRegistryAlreadyExists} + } + } + + if payload.Name != "" { + registry.Name = payload.Name + } + + if payload.URL != "" { + registry.URL = payload.URL + } + + if payload.Authentication { + registry.Authentication = true + registry.Username = payload.Username + registry.Password = payload.Password + } else { + registry.Authentication = false + registry.Username = "" + registry.Password = "" + } + + err = handler.RegistryService.UpdateRegistry(registry.ID, registry) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist registry changes inside the database", err} + } + + return response.JSON(w, registry) +} diff --git a/api/http/handler/registries/registry_update_access.go b/api/http/handler/registries/registry_update_access.go new file mode 100644 index 000000000..a8234e95c --- /dev/null +++ b/api/http/handler/registries/registry_update_access.go @@ -0,0 +1,63 @@ +package registries + +import ( + "net/http" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" +) + +type registryUpdateAccessPayload struct { + AuthorizedUsers []int + AuthorizedTeams []int +} + +func (payload *registryUpdateAccessPayload) Validate(r *http.Request) error { + return nil +} + +// PUT request on /api/registries/:id/access +func (handler *Handler) registryUpdateAccess(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + registryID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid registry identifier route variable", err} + } + + var payload registryUpdateAccessPayload + err = request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + registry, err := handler.RegistryService.Registry(portainer.RegistryID(registryID)) + if err == portainer.ErrEndpointGroupNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find a registry with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a registry with the specified identifier inside the database", err} + } + + if payload.AuthorizedUsers != nil { + authorizedUserIDs := []portainer.UserID{} + for _, value := range payload.AuthorizedUsers { + authorizedUserIDs = append(authorizedUserIDs, portainer.UserID(value)) + } + registry.AuthorizedUsers = authorizedUserIDs + } + + if payload.AuthorizedTeams != nil { + authorizedTeamIDs := []portainer.TeamID{} + for _, value := range payload.AuthorizedTeams { + authorizedTeamIDs = append(authorizedTeamIDs, portainer.TeamID(value)) + } + registry.AuthorizedTeams = authorizedTeamIDs + } + + err = handler.RegistryService.UpdateRegistry(registry.ID, registry) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist registry changes inside the database", err} + } + + return response.JSON(w, registry) +} diff --git a/api/http/handler/registry.go b/api/http/handler/registry.go deleted file mode 100644 index 37cb2c971..000000000 --- a/api/http/handler/registry.go +++ /dev/null @@ -1,320 +0,0 @@ -package handler - -import ( - "github.com/portainer/portainer" - httperror "github.com/portainer/portainer/http/error" - "github.com/portainer/portainer/http/security" - - "encoding/json" - "log" - "net/http" - "os" - "strconv" - - "github.com/asaskevich/govalidator" - "github.com/gorilla/mux" -) - -// RegistryHandler represents an HTTP API handler for managing Docker registries. -type RegistryHandler struct { - *mux.Router - Logger *log.Logger - RegistryService portainer.RegistryService -} - -// NewRegistryHandler returns a new instance of RegistryHandler. -func NewRegistryHandler(bouncer *security.RequestBouncer) *RegistryHandler { - h := &RegistryHandler{ - Router: mux.NewRouter(), - Logger: log.New(os.Stderr, "", log.LstdFlags), - } - h.Handle("/registries", - bouncer.AdministratorAccess(http.HandlerFunc(h.handlePostRegistries))).Methods(http.MethodPost) - h.Handle("/registries", - bouncer.RestrictedAccess(http.HandlerFunc(h.handleGetRegistries))).Methods(http.MethodGet) - h.Handle("/registries/{id}", - bouncer.AdministratorAccess(http.HandlerFunc(h.handleGetRegistry))).Methods(http.MethodGet) - h.Handle("/registries/{id}", - bouncer.AdministratorAccess(http.HandlerFunc(h.handlePutRegistry))).Methods(http.MethodPut) - h.Handle("/registries/{id}/access", - bouncer.AdministratorAccess(http.HandlerFunc(h.handlePutRegistryAccess))).Methods(http.MethodPut) - h.Handle("/registries/{id}", - bouncer.AdministratorAccess(http.HandlerFunc(h.handleDeleteRegistry))).Methods(http.MethodDelete) - - return h -} - -type ( - postRegistriesRequest struct { - Name string `valid:"required"` - URL string `valid:"required"` - Authentication bool `valid:""` - Username string `valid:""` - Password string `valid:""` - } - - postRegistriesResponse struct { - ID int `json:"Id"` - } - - putRegistryAccessRequest struct { - AuthorizedUsers []int `valid:"-"` - AuthorizedTeams []int `valid:"-"` - } - - putRegistriesRequest struct { - Name string `valid:"required"` - URL string `valid:"required"` - Authentication bool `valid:""` - Username string `valid:""` - Password string `valid:""` - } -) - -// handleGetRegistries handles GET requests on /registries -func (handler *RegistryHandler) handleGetRegistries(w http.ResponseWriter, r *http.Request) { - securityContext, err := security.RetrieveRestrictedRequestContext(r) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - registries, err := handler.RegistryService.Registries() - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - filteredRegistries, err := security.FilterRegistries(registries, securityContext) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - for i := range filteredRegistries { - filteredRegistries[i].Password = "" - } - - encodeJSON(w, filteredRegistries, handler.Logger) -} - -// handlePostRegistries handles POST requests on /registries -func (handler *RegistryHandler) handlePostRegistries(w http.ResponseWriter, r *http.Request) { - var req postRegistriesRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger) - return - } - - _, err := govalidator.ValidateStruct(req) - if err != nil { - httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) - return - } - - registries, err := handler.RegistryService.Registries() - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - for _, r := range registries { - if r.URL == req.URL { - httperror.WriteErrorResponse(w, portainer.ErrRegistryAlreadyExists, http.StatusConflict, handler.Logger) - return - } - } - - registry := &portainer.Registry{ - Name: req.Name, - URL: req.URL, - Authentication: req.Authentication, - Username: req.Username, - Password: req.Password, - AuthorizedUsers: []portainer.UserID{}, - AuthorizedTeams: []portainer.TeamID{}, - } - - err = handler.RegistryService.CreateRegistry(registry) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - encodeJSON(w, &postRegistriesResponse{ID: int(registry.ID)}, handler.Logger) -} - -// handleGetRegistry handles GET requests on /registries/:id -func (handler *RegistryHandler) handleGetRegistry(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - id := vars["id"] - - registryID, err := strconv.Atoi(id) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) - return - } - - registry, err := handler.RegistryService.Registry(portainer.RegistryID(registryID)) - if err == portainer.ErrRegistryNotFound { - httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) - return - } else if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - registry.Password = "" - - encodeJSON(w, registry, handler.Logger) -} - -// handlePutRegistryAccess handles PUT requests on /registries/:id/access -func (handler *RegistryHandler) handlePutRegistryAccess(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - id := vars["id"] - - registryID, err := strconv.Atoi(id) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) - return - } - - var req putRegistryAccessRequest - if err = json.NewDecoder(r.Body).Decode(&req); err != nil { - httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger) - return - } - - _, err = govalidator.ValidateStruct(req) - if err != nil { - httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) - return - } - - registry, err := handler.RegistryService.Registry(portainer.RegistryID(registryID)) - if err == portainer.ErrRegistryNotFound { - httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) - return - } else if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - if req.AuthorizedUsers != nil { - authorizedUserIDs := []portainer.UserID{} - for _, value := range req.AuthorizedUsers { - authorizedUserIDs = append(authorizedUserIDs, portainer.UserID(value)) - } - registry.AuthorizedUsers = authorizedUserIDs - } - - if req.AuthorizedTeams != nil { - authorizedTeamIDs := []portainer.TeamID{} - for _, value := range req.AuthorizedTeams { - authorizedTeamIDs = append(authorizedTeamIDs, portainer.TeamID(value)) - } - registry.AuthorizedTeams = authorizedTeamIDs - } - - err = handler.RegistryService.UpdateRegistry(registry.ID, registry) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } -} - -// handlePutRegistry handles PUT requests on /registries/:id -func (handler *RegistryHandler) handlePutRegistry(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - id := vars["id"] - - registryID, err := strconv.Atoi(id) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) - return - } - - var req putRegistriesRequest - if err = json.NewDecoder(r.Body).Decode(&req); err != nil { - httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger) - return - } - - _, err = govalidator.ValidateStruct(req) - if err != nil { - httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) - return - } - - registry, err := handler.RegistryService.Registry(portainer.RegistryID(registryID)) - if err == portainer.ErrRegistryNotFound { - httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) - return - } else if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - registries, err := handler.RegistryService.Registries() - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - for _, r := range registries { - if r.URL == req.URL && r.ID != registry.ID { - httperror.WriteErrorResponse(w, portainer.ErrRegistryAlreadyExists, http.StatusConflict, handler.Logger) - return - } - } - - if req.Name != "" { - registry.Name = req.Name - } - - if req.URL != "" { - registry.URL = req.URL - } - - if req.Authentication { - registry.Authentication = true - registry.Username = req.Username - registry.Password = req.Password - } else { - registry.Authentication = false - registry.Username = "" - registry.Password = "" - } - - err = handler.RegistryService.UpdateRegistry(registry.ID, registry) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } -} - -// handleDeleteRegistry handles DELETE requests on /registries/:id -func (handler *RegistryHandler) handleDeleteRegistry(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - id := vars["id"] - - registryID, err := strconv.Atoi(id) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) - return - } - - _, err = handler.RegistryService.Registry(portainer.RegistryID(registryID)) - if err == portainer.ErrRegistryNotFound { - httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) - return - } else if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - err = handler.RegistryService.DeleteRegistry(portainer.RegistryID(registryID)) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } -} diff --git a/api/http/handler/resource_control.go b/api/http/handler/resource_control.go deleted file mode 100644 index fa939bb12..000000000 --- a/api/http/handler/resource_control.go +++ /dev/null @@ -1,266 +0,0 @@ -package handler - -import ( - "encoding/json" - "strconv" - - "github.com/asaskevich/govalidator" - "github.com/portainer/portainer" - httperror "github.com/portainer/portainer/http/error" - "github.com/portainer/portainer/http/security" - - "log" - "net/http" - "os" - - "github.com/gorilla/mux" -) - -// ResourceHandler represents an HTTP API handler for managing resource controls. -type ResourceHandler struct { - *mux.Router - Logger *log.Logger - ResourceControlService portainer.ResourceControlService -} - -// NewResourceHandler returns a new instance of ResourceHandler. -func NewResourceHandler(bouncer *security.RequestBouncer) *ResourceHandler { - h := &ResourceHandler{ - Router: mux.NewRouter(), - Logger: log.New(os.Stderr, "", log.LstdFlags), - } - h.Handle("/resource_controls", - bouncer.RestrictedAccess(http.HandlerFunc(h.handlePostResources))).Methods(http.MethodPost) - h.Handle("/resource_controls/{id}", - bouncer.RestrictedAccess(http.HandlerFunc(h.handlePutResources))).Methods(http.MethodPut) - h.Handle("/resource_controls/{id}", - bouncer.RestrictedAccess(http.HandlerFunc(h.handleDeleteResources))).Methods(http.MethodDelete) - - return h -} - -type ( - postResourcesRequest struct { - ResourceID string `valid:"required"` - Type string `valid:"required"` - AdministratorsOnly bool `valid:"-"` - Users []int `valid:"-"` - Teams []int `valid:"-"` - SubResourceIDs []string `valid:"-"` - } - - putResourcesRequest struct { - AdministratorsOnly bool `valid:"-"` - Users []int `valid:"-"` - Teams []int `valid:"-"` - } -) - -// handlePostResources handles POST requests on /resources -func (handler *ResourceHandler) handlePostResources(w http.ResponseWriter, r *http.Request) { - var req postResourcesRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger) - return - } - - _, err := govalidator.ValidateStruct(req) - if err != nil { - httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) - return - } - - var resourceControlType portainer.ResourceControlType - switch req.Type { - case "container": - resourceControlType = portainer.ContainerResourceControl - case "service": - resourceControlType = portainer.ServiceResourceControl - case "volume": - resourceControlType = portainer.VolumeResourceControl - case "network": - resourceControlType = portainer.NetworkResourceControl - case "secret": - resourceControlType = portainer.SecretResourceControl - case "stack": - resourceControlType = portainer.StackResourceControl - case "config": - resourceControlType = portainer.ConfigResourceControl - default: - httperror.WriteErrorResponse(w, portainer.ErrInvalidResourceControlType, http.StatusBadRequest, handler.Logger) - return - } - - if len(req.Users) == 0 && len(req.Teams) == 0 && !req.AdministratorsOnly { - httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) - return - } - - rc, err := handler.ResourceControlService.ResourceControlByResourceID(req.ResourceID) - if err != nil && err != portainer.ErrResourceControlNotFound { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - if rc != nil { - httperror.WriteErrorResponse(w, portainer.ErrResourceControlAlreadyExists, http.StatusConflict, handler.Logger) - return - } - - var userAccesses = make([]portainer.UserResourceAccess, 0) - for _, v := range req.Users { - userAccess := portainer.UserResourceAccess{ - UserID: portainer.UserID(v), - AccessLevel: portainer.ReadWriteAccessLevel, - } - userAccesses = append(userAccesses, userAccess) - } - - var teamAccesses = make([]portainer.TeamResourceAccess, 0) - for _, v := range req.Teams { - teamAccess := portainer.TeamResourceAccess{ - TeamID: portainer.TeamID(v), - AccessLevel: portainer.ReadWriteAccessLevel, - } - teamAccesses = append(teamAccesses, teamAccess) - } - - resourceControl := portainer.ResourceControl{ - ResourceID: req.ResourceID, - SubResourceIDs: req.SubResourceIDs, - Type: resourceControlType, - AdministratorsOnly: req.AdministratorsOnly, - UserAccesses: userAccesses, - TeamAccesses: teamAccesses, - } - - securityContext, err := security.RetrieveRestrictedRequestContext(r) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - if !security.AuthorizedResourceControlCreation(&resourceControl, securityContext) { - httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, handler.Logger) - return - } - - err = handler.ResourceControlService.CreateResourceControl(&resourceControl) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - return -} - -// handlePutResources handles PUT requests on /resources/:id -func (handler *ResourceHandler) handlePutResources(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - id := vars["id"] - - resourceControlID, err := strconv.Atoi(id) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) - return - } - - var req putResourcesRequest - if err = json.NewDecoder(r.Body).Decode(&req); err != nil { - httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger) - return - } - - _, err = govalidator.ValidateStruct(req) - if err != nil { - httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) - return - } - - resourceControl, err := handler.ResourceControlService.ResourceControl(portainer.ResourceControlID(resourceControlID)) - - if err == portainer.ErrResourceControlNotFound { - httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) - return - } else if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - resourceControl.AdministratorsOnly = req.AdministratorsOnly - - var userAccesses = make([]portainer.UserResourceAccess, 0) - for _, v := range req.Users { - userAccess := portainer.UserResourceAccess{ - UserID: portainer.UserID(v), - AccessLevel: portainer.ReadWriteAccessLevel, - } - userAccesses = append(userAccesses, userAccess) - } - resourceControl.UserAccesses = userAccesses - - var teamAccesses = make([]portainer.TeamResourceAccess, 0) - for _, v := range req.Teams { - teamAccess := portainer.TeamResourceAccess{ - TeamID: portainer.TeamID(v), - AccessLevel: portainer.ReadWriteAccessLevel, - } - teamAccesses = append(teamAccesses, teamAccess) - } - resourceControl.TeamAccesses = teamAccesses - - securityContext, err := security.RetrieveRestrictedRequestContext(r) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - if !security.AuthorizedResourceControlUpdate(resourceControl, securityContext) { - httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, handler.Logger) - return - } - - err = handler.ResourceControlService.UpdateResourceControl(resourceControl.ID, resourceControl) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } -} - -// handleDeleteResources handles DELETE requests on /resources/:id -func (handler *ResourceHandler) handleDeleteResources(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - id := vars["id"] - - resourceControlID, err := strconv.Atoi(id) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) - return - } - - resourceControl, err := handler.ResourceControlService.ResourceControl(portainer.ResourceControlID(resourceControlID)) - - if err == portainer.ErrResourceControlNotFound { - httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) - return - } else if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - securityContext, err := security.RetrieveRestrictedRequestContext(r) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - if !security.AuthorizedResourceControlDeletion(resourceControl, securityContext) { - httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, handler.Logger) - return - } - - err = handler.ResourceControlService.DeleteResourceControl(portainer.ResourceControlID(resourceControlID)) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } -} diff --git a/api/http/handler/resourcecontrols/handler.go b/api/http/handler/resourcecontrols/handler.go new file mode 100644 index 000000000..4ad474f6b --- /dev/null +++ b/api/http/handler/resourcecontrols/handler.go @@ -0,0 +1,31 @@ +package resourcecontrols + +import ( + "net/http" + + "github.com/gorilla/mux" + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/security" +) + +// Handler is the HTTP handler used to handle resource control operations. +type Handler struct { + *mux.Router + ResourceControlService portainer.ResourceControlService +} + +// NewHandler creates a handler to manage resource control operations. +func NewHandler(bouncer *security.RequestBouncer) *Handler { + h := &Handler{ + Router: mux.NewRouter(), + } + h.Handle("/resource_controls", + bouncer.RestrictedAccess(httperror.LoggerHandler(h.resourceControlCreate))).Methods(http.MethodPost) + h.Handle("/resource_controls/{id}", + bouncer.RestrictedAccess(httperror.LoggerHandler(h.resourceControlUpdate))).Methods(http.MethodPut) + h.Handle("/resource_controls/{id}", + bouncer.RestrictedAccess(httperror.LoggerHandler(h.resourceControlDelete))).Methods(http.MethodDelete) + + return h +} diff --git a/api/http/handler/resourcecontrols/resourcecontrol_create.go b/api/http/handler/resourcecontrols/resourcecontrol_create.go new file mode 100644 index 000000000..4dc41e406 --- /dev/null +++ b/api/http/handler/resourcecontrols/resourcecontrol_create.go @@ -0,0 +1,116 @@ +package resourcecontrols + +import ( + "net/http" + + "github.com/asaskevich/govalidator" + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" + "github.com/portainer/portainer/http/security" +) + +type resourceControlCreatePayload struct { + ResourceID string + Type string + AdministratorsOnly bool + Users []int + Teams []int + SubResourceIDs []string +} + +func (payload *resourceControlCreatePayload) Validate(r *http.Request) error { + if govalidator.IsNull(payload.ResourceID) { + return portainer.Error("Invalid resource identifier") + } + + if govalidator.IsNull(payload.Type) { + return portainer.Error("Invalid type") + } + + if len(payload.Users) == 0 && len(payload.Teams) == 0 && !payload.AdministratorsOnly { + return portainer.Error("Invalid resource control declaration. Must specify Users, Teams or AdministratorOnly") + } + return nil +} + +// POST request on /api/resource_controls +func (handler *Handler) resourceControlCreate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + var payload resourceControlCreatePayload + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + var resourceControlType portainer.ResourceControlType + switch payload.Type { + case "container": + resourceControlType = portainer.ContainerResourceControl + case "service": + resourceControlType = portainer.ServiceResourceControl + case "volume": + resourceControlType = portainer.VolumeResourceControl + case "network": + resourceControlType = portainer.NetworkResourceControl + case "secret": + resourceControlType = portainer.SecretResourceControl + case "stack": + resourceControlType = portainer.StackResourceControl + case "config": + resourceControlType = portainer.ConfigResourceControl + default: + return &httperror.HandlerError{http.StatusBadRequest, "Invalid type value. Value must be one of: container, service, volume, network, secret, stack or config", portainer.ErrInvalidResourceControlType} + } + + rc, err := handler.ResourceControlService.ResourceControlByResourceID(payload.ResourceID) + if err != nil && err != portainer.ErrResourceControlNotFound { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve resource controls from the database", err} + } + if rc != nil { + return &httperror.HandlerError{http.StatusConflict, "A resource control is already associated to this resource", portainer.ErrResourceControlAlreadyExists} + } + + var userAccesses = make([]portainer.UserResourceAccess, 0) + for _, v := range payload.Users { + userAccess := portainer.UserResourceAccess{ + UserID: portainer.UserID(v), + AccessLevel: portainer.ReadWriteAccessLevel, + } + userAccesses = append(userAccesses, userAccess) + } + + var teamAccesses = make([]portainer.TeamResourceAccess, 0) + for _, v := range payload.Teams { + teamAccess := portainer.TeamResourceAccess{ + TeamID: portainer.TeamID(v), + AccessLevel: portainer.ReadWriteAccessLevel, + } + teamAccesses = append(teamAccesses, teamAccess) + } + + resourceControl := portainer.ResourceControl{ + ResourceID: payload.ResourceID, + SubResourceIDs: payload.SubResourceIDs, + Type: resourceControlType, + AdministratorsOnly: payload.AdministratorsOnly, + UserAccesses: userAccesses, + TeamAccesses: teamAccesses, + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} + } + + if !security.AuthorizedResourceControlCreation(&resourceControl, securityContext) { + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to create a resource control for the specified resource", portainer.ErrResourceAccessDenied} + } + + err = handler.ResourceControlService.CreateResourceControl(&resourceControl) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the resource control inside the database", err} + } + + return response.JSON(w, resourceControl) +} diff --git a/api/http/handler/resourcecontrols/resourcecontrol_delete.go b/api/http/handler/resourcecontrols/resourcecontrol_delete.go new file mode 100644 index 000000000..47a507e9d --- /dev/null +++ b/api/http/handler/resourcecontrols/resourcecontrol_delete.go @@ -0,0 +1,42 @@ +package resourcecontrols + +import ( + "net/http" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" + "github.com/portainer/portainer/http/security" +) + +// DELETE request on /api/resource_controls/:id +func (handler *Handler) resourceControlDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + resourceControlID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid resource control identifier route variable", err} + } + + resourceControl, err := handler.ResourceControlService.ResourceControl(portainer.ResourceControlID(resourceControlID)) + if err == portainer.ErrResourceControlNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find a resource control with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a resource control with with the specified identifier inside the database", err} + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} + } + + if !security.AuthorizedResourceControlDeletion(resourceControl, securityContext) { + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to delete the resource control", portainer.ErrResourceAccessDenied} + } + + err = handler.ResourceControlService.DeleteResourceControl(portainer.ResourceControlID(resourceControlID)) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove the resource control from the database", err} + } + + return response.Empty(w) +} diff --git a/api/http/handler/resourcecontrols/resourcecontrol_update.go b/api/http/handler/resourcecontrols/resourcecontrol_update.go new file mode 100644 index 000000000..1a5aa29d8 --- /dev/null +++ b/api/http/handler/resourcecontrols/resourcecontrol_update.go @@ -0,0 +1,83 @@ +package resourcecontrols + +import ( + "net/http" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" + "github.com/portainer/portainer/http/security" +) + +type resourceControlUpdatePayload struct { + AdministratorsOnly bool + Users []int + Teams []int +} + +func (payload *resourceControlUpdatePayload) Validate(r *http.Request) error { + if len(payload.Users) == 0 && len(payload.Teams) == 0 && !payload.AdministratorsOnly { + return portainer.Error("Invalid resource control declaration. Must specify Users, Teams or AdministratorOnly") + } + return nil +} + +// PUT request on /api/resource_controls/:id +func (handler *Handler) resourceControlUpdate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + resourceControlID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid resource control identifier route variable", err} + } + + var payload resourceControlUpdatePayload + err = request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + resourceControl, err := handler.ResourceControlService.ResourceControl(portainer.ResourceControlID(resourceControlID)) + if err == portainer.ErrResourceControlNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find a resource control with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a resource control with with the specified identifier inside the database", err} + } + + resourceControl.AdministratorsOnly = payload.AdministratorsOnly + + var userAccesses = make([]portainer.UserResourceAccess, 0) + for _, v := range payload.Users { + userAccess := portainer.UserResourceAccess{ + UserID: portainer.UserID(v), + AccessLevel: portainer.ReadWriteAccessLevel, + } + userAccesses = append(userAccesses, userAccess) + } + resourceControl.UserAccesses = userAccesses + + var teamAccesses = make([]portainer.TeamResourceAccess, 0) + for _, v := range payload.Teams { + teamAccess := portainer.TeamResourceAccess{ + TeamID: portainer.TeamID(v), + AccessLevel: portainer.ReadWriteAccessLevel, + } + teamAccesses = append(teamAccesses, teamAccess) + } + resourceControl.TeamAccesses = teamAccesses + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} + } + + if !security.AuthorizedResourceControlUpdate(resourceControl, securityContext) { + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to update the resource control", portainer.ErrResourceAccessDenied} + } + + err = handler.ResourceControlService.UpdateResourceControl(resourceControl.ID, resourceControl) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist resource control changes inside the database", err} + } + + return response.JSON(w, resourceControl) +} diff --git a/api/http/handler/settings.go b/api/http/handler/settings.go deleted file mode 100644 index 2df31b62e..000000000 --- a/api/http/handler/settings.go +++ /dev/null @@ -1,177 +0,0 @@ -package handler - -import ( - "encoding/json" - - "github.com/asaskevich/govalidator" - "github.com/portainer/portainer" - "github.com/portainer/portainer/filesystem" - httperror "github.com/portainer/portainer/http/error" - "github.com/portainer/portainer/http/security" - - "log" - "net/http" - "os" - - "github.com/gorilla/mux" -) - -// SettingsHandler represents an HTTP API handler for managing Settings. -type SettingsHandler struct { - *mux.Router - Logger *log.Logger - SettingsService portainer.SettingsService - LDAPService portainer.LDAPService - FileService portainer.FileService -} - -// NewSettingsHandler returns a new instance of OldSettingsHandler. -func NewSettingsHandler(bouncer *security.RequestBouncer) *SettingsHandler { - h := &SettingsHandler{ - Router: mux.NewRouter(), - Logger: log.New(os.Stderr, "", log.LstdFlags), - } - h.Handle("/settings", - bouncer.AdministratorAccess(http.HandlerFunc(h.handleGetSettings))).Methods(http.MethodGet) - h.Handle("/settings", - bouncer.AdministratorAccess(http.HandlerFunc(h.handlePutSettings))).Methods(http.MethodPut) - h.Handle("/settings/public", - bouncer.PublicAccess(http.HandlerFunc(h.handleGetPublicSettings))).Methods(http.MethodGet) - h.Handle("/settings/authentication/checkLDAP", - bouncer.AdministratorAccess(http.HandlerFunc(h.handlePutSettingsLDAPCheck))).Methods(http.MethodPut) - - return h -} - -type ( - publicSettingsResponse struct { - LogoURL string `json:"LogoURL"` - DisplayExternalContributors bool `json:"DisplayExternalContributors"` - AuthenticationMethod portainer.AuthenticationMethod `json:"AuthenticationMethod"` - AllowBindMountsForRegularUsers bool `json:"AllowBindMountsForRegularUsers"` - AllowPrivilegedModeForRegularUsers bool `json:"AllowPrivilegedModeForRegularUsers"` - } - - putSettingsRequest struct { - TemplatesURL string `valid:"required"` - LogoURL string `valid:""` - BlackListedLabels []portainer.Pair `valid:""` - DisplayExternalContributors bool `valid:""` - AuthenticationMethod int `valid:"required"` - LDAPSettings portainer.LDAPSettings `valid:""` - AllowBindMountsForRegularUsers bool `valid:""` - AllowPrivilegedModeForRegularUsers bool `valid:""` - } - - putSettingsLDAPCheckRequest struct { - LDAPSettings portainer.LDAPSettings `valid:""` - } -) - -// handleGetSettings handles GET requests on /settings -func (handler *SettingsHandler) handleGetSettings(w http.ResponseWriter, r *http.Request) { - settings, err := handler.SettingsService.Settings() - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - encodeJSON(w, settings, handler.Logger) - return -} - -// handleGetPublicSettings handles GET requests on /settings/public -func (handler *SettingsHandler) handleGetPublicSettings(w http.ResponseWriter, r *http.Request) { - settings, err := handler.SettingsService.Settings() - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - publicSettings := &publicSettingsResponse{ - LogoURL: settings.LogoURL, - DisplayExternalContributors: settings.DisplayExternalContributors, - AuthenticationMethod: settings.AuthenticationMethod, - AllowBindMountsForRegularUsers: settings.AllowBindMountsForRegularUsers, - AllowPrivilegedModeForRegularUsers: settings.AllowPrivilegedModeForRegularUsers, - } - - encodeJSON(w, publicSettings, handler.Logger) - return -} - -// handlePutSettings handles PUT requests on /settings -func (handler *SettingsHandler) handlePutSettings(w http.ResponseWriter, r *http.Request) { - var req putSettingsRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger) - return - } - - _, err := govalidator.ValidateStruct(req) - if err != nil { - httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) - return - } - - settings := &portainer.Settings{ - TemplatesURL: req.TemplatesURL, - LogoURL: req.LogoURL, - BlackListedLabels: req.BlackListedLabels, - DisplayExternalContributors: req.DisplayExternalContributors, - LDAPSettings: req.LDAPSettings, - AllowBindMountsForRegularUsers: req.AllowBindMountsForRegularUsers, - AllowPrivilegedModeForRegularUsers: req.AllowPrivilegedModeForRegularUsers, - } - - if req.AuthenticationMethod == 1 { - settings.AuthenticationMethod = portainer.AuthenticationInternal - } else if req.AuthenticationMethod == 2 { - settings.AuthenticationMethod = portainer.AuthenticationLDAP - } else { - httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) - return - } - - if (settings.LDAPSettings.TLSConfig.TLS || settings.LDAPSettings.StartTLS) && !settings.LDAPSettings.TLSConfig.TLSSkipVerify { - caCertPath, _ := handler.FileService.GetPathForTLSFile(filesystem.LDAPStorePath, portainer.TLSFileCA) - settings.LDAPSettings.TLSConfig.TLSCACertPath = caCertPath - } else { - settings.LDAPSettings.TLSConfig.TLSCACertPath = "" - err := handler.FileService.DeleteTLSFiles(filesystem.LDAPStorePath) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - } - } - - err = handler.SettingsService.StoreSettings(settings) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - } -} - -// handlePutSettingsLDAPCheck handles PUT requests on /settings/ldap/check -func (handler *SettingsHandler) handlePutSettingsLDAPCheck(w http.ResponseWriter, r *http.Request) { - var req putSettingsLDAPCheckRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger) - return - } - - _, err := govalidator.ValidateStruct(req) - if err != nil { - httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) - return - } - - if (req.LDAPSettings.TLSConfig.TLS || req.LDAPSettings.StartTLS) && !req.LDAPSettings.TLSConfig.TLSSkipVerify { - caCertPath, _ := handler.FileService.GetPathForTLSFile(filesystem.LDAPStorePath, portainer.TLSFileCA) - req.LDAPSettings.TLSConfig.TLSCACertPath = caCertPath - } - - err = handler.LDAPService.TestConnectivity(&req.LDAPSettings) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } -} diff --git a/api/http/handler/settings/handler.go b/api/http/handler/settings/handler.go new file mode 100644 index 000000000..0850ec83e --- /dev/null +++ b/api/http/handler/settings/handler.go @@ -0,0 +1,35 @@ +package settings + +import ( + "net/http" + + "github.com/gorilla/mux" + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/security" +) + +// Handler is the HTTP handler used to handle settings operations. +type Handler struct { + *mux.Router + SettingsService portainer.SettingsService + LDAPService portainer.LDAPService + FileService portainer.FileService +} + +// NewHandler creates a handler to manage settings operations. +func NewHandler(bouncer *security.RequestBouncer) *Handler { + h := &Handler{ + Router: mux.NewRouter(), + } + h.Handle("/settings", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.settingsInspect))).Methods(http.MethodGet) + h.Handle("/settings", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.settingsUpdate))).Methods(http.MethodPut) + h.Handle("/settings/public", + bouncer.PublicAccess(httperror.LoggerHandler(h.settingsPublic))).Methods(http.MethodGet) + h.Handle("/settings/authentication/checkLDAP", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.settingsLDAPCheck))).Methods(http.MethodPut) + + return h +} diff --git a/api/http/handler/settings/settings_inspect.go b/api/http/handler/settings/settings_inspect.go new file mode 100644 index 000000000..48da08612 --- /dev/null +++ b/api/http/handler/settings/settings_inspect.go @@ -0,0 +1,18 @@ +package settings + +import ( + "net/http" + + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/response" +) + +// GET request on /api/settings +func (handler *Handler) settingsInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + settings, err := handler.SettingsService.Settings() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve the settings from the database", err} + } + + return response.JSON(w, settings) +} diff --git a/api/http/handler/settings/settings_ldap_check.go b/api/http/handler/settings/settings_ldap_check.go new file mode 100644 index 000000000..80d058e33 --- /dev/null +++ b/api/http/handler/settings/settings_ldap_check.go @@ -0,0 +1,40 @@ +package settings + +import ( + "net/http" + + "github.com/portainer/portainer" + "github.com/portainer/portainer/filesystem" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" +) + +type settingsLDAPCheckPayload struct { + LDAPSettings portainer.LDAPSettings +} + +func (payload *settingsLDAPCheckPayload) Validate(r *http.Request) error { + return nil +} + +// PUT request on /settings/ldap/check +func (handler *Handler) settingsLDAPCheck(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + var payload settingsLDAPCheckPayload + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + if (payload.LDAPSettings.TLSConfig.TLS || payload.LDAPSettings.StartTLS) && !payload.LDAPSettings.TLSConfig.TLSSkipVerify { + caCertPath, _ := handler.FileService.GetPathForTLSFile(filesystem.LDAPStorePath, portainer.TLSFileCA) + payload.LDAPSettings.TLSConfig.TLSCACertPath = caCertPath + } + + err = handler.LDAPService.TestConnectivity(&payload.LDAPSettings) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to connect to LDAP server", err} + } + + return response.Empty(w) +} diff --git a/api/http/handler/settings/settings_public.go b/api/http/handler/settings/settings_public.go new file mode 100644 index 000000000..ef76231f0 --- /dev/null +++ b/api/http/handler/settings/settings_public.go @@ -0,0 +1,35 @@ +package settings + +import ( + "net/http" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/response" +) + +type publicSettingsResponse struct { + LogoURL string `json:"LogoURL"` + DisplayExternalContributors bool `json:"DisplayExternalContributors"` + AuthenticationMethod portainer.AuthenticationMethod `json:"AuthenticationMethod"` + AllowBindMountsForRegularUsers bool `json:"AllowBindMountsForRegularUsers"` + AllowPrivilegedModeForRegularUsers bool `json:"AllowPrivilegedModeForRegularUsers"` +} + +// GET request on /api/settings/public +func (handler *Handler) settingsPublic(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + settings, err := handler.SettingsService.Settings() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve the settings from the database", err} + } + + publicSettings := &publicSettingsResponse{ + LogoURL: settings.LogoURL, + DisplayExternalContributors: settings.DisplayExternalContributors, + AuthenticationMethod: settings.AuthenticationMethod, + AllowBindMountsForRegularUsers: settings.AllowBindMountsForRegularUsers, + AllowPrivilegedModeForRegularUsers: settings.AllowPrivilegedModeForRegularUsers, + } + + return response.JSON(w, publicSettings) +} diff --git a/api/http/handler/settings/settings_update.go b/api/http/handler/settings/settings_update.go new file mode 100644 index 000000000..defe9cd6b --- /dev/null +++ b/api/http/handler/settings/settings_update.go @@ -0,0 +1,85 @@ +package settings + +import ( + "net/http" + + "github.com/asaskevich/govalidator" + "github.com/portainer/portainer" + "github.com/portainer/portainer/filesystem" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" +) + +type settingsUpdatePayload struct { + TemplatesURL string + LogoURL string + BlackListedLabels []portainer.Pair + DisplayExternalContributors bool + AuthenticationMethod int + LDAPSettings portainer.LDAPSettings + AllowBindMountsForRegularUsers bool + AllowPrivilegedModeForRegularUsers bool +} + +func (payload *settingsUpdatePayload) Validate(r *http.Request) error { + if govalidator.IsNull(payload.TemplatesURL) || !govalidator.IsURL(payload.TemplatesURL) { + return portainer.Error("Invalid templates URL. Must correspond to a valid URL format") + } + if payload.AuthenticationMethod == 0 { + return portainer.Error("Invalid authentication method") + } + if payload.AuthenticationMethod != 1 && payload.AuthenticationMethod != 2 { + return portainer.Error("Invalid authentication method value. Value must be one of: 1 (internal) or 2 (LDAP/AD)") + } + if !govalidator.IsNull(payload.LogoURL) && !govalidator.IsURL(payload.LogoURL) { + return portainer.Error("Invalid logo URL. Must correspond to a valid URL format") + } + return nil +} + +// PUT request on /api/settings +func (handler *Handler) settingsUpdate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + var payload settingsUpdatePayload + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + settings := &portainer.Settings{ + TemplatesURL: payload.TemplatesURL, + LogoURL: payload.LogoURL, + BlackListedLabels: payload.BlackListedLabels, + DisplayExternalContributors: payload.DisplayExternalContributors, + LDAPSettings: payload.LDAPSettings, + AllowBindMountsForRegularUsers: payload.AllowBindMountsForRegularUsers, + AllowPrivilegedModeForRegularUsers: payload.AllowPrivilegedModeForRegularUsers, + } + + settings.AuthenticationMethod = portainer.AuthenticationMethod(payload.AuthenticationMethod) + tlsError := handler.updateTLS(settings) + if tlsError != nil { + return tlsError + } + + err = handler.SettingsService.StoreSettings(settings) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist settings changes inside the database", err} + } + + return response.JSON(w, settings) +} + +func (handler *Handler) updateTLS(settings *portainer.Settings) *httperror.HandlerError { + if (settings.LDAPSettings.TLSConfig.TLS || settings.LDAPSettings.StartTLS) && !settings.LDAPSettings.TLSConfig.TLSSkipVerify { + caCertPath, _ := handler.FileService.GetPathForTLSFile(filesystem.LDAPStorePath, portainer.TLSFileCA) + settings.LDAPSettings.TLSConfig.TLSCACertPath = caCertPath + } else { + settings.LDAPSettings.TLSConfig.TLSCACertPath = "" + err := handler.FileService.DeleteTLSFiles(filesystem.LDAPStorePath) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove TLS files from disk", err} + } + } + return nil +} diff --git a/api/http/handler/stack.go b/api/http/handler/stack.go deleted file mode 100644 index 5b9e53187..000000000 --- a/api/http/handler/stack.go +++ /dev/null @@ -1,794 +0,0 @@ -package handler - -import ( - "encoding/json" - "path" - "strconv" - "strings" - "sync" - - "github.com/asaskevich/govalidator" - "github.com/portainer/portainer" - "github.com/portainer/portainer/filesystem" - httperror "github.com/portainer/portainer/http/error" - "github.com/portainer/portainer/http/proxy" - "github.com/portainer/portainer/http/security" - - "log" - "net/http" - "os" - - "github.com/gorilla/mux" -) - -// StackHandler represents an HTTP API handler for managing Stack. -type StackHandler struct { - stackCreationMutex *sync.Mutex - stackDeletionMutex *sync.Mutex - *mux.Router - Logger *log.Logger - FileService portainer.FileService - GitService portainer.GitService - StackService portainer.StackService - EndpointService portainer.EndpointService - ResourceControlService portainer.ResourceControlService - RegistryService portainer.RegistryService - DockerHubService portainer.DockerHubService - StackManager portainer.StackManager -} - -type stackDeploymentConfig struct { - endpoint *portainer.Endpoint - stack *portainer.Stack - prune bool - dockerhub *portainer.DockerHub - registries []portainer.Registry -} - -// NewStackHandler returns a new instance of StackHandler. -func NewStackHandler(bouncer *security.RequestBouncer) *StackHandler { - h := &StackHandler{ - Router: mux.NewRouter(), - stackCreationMutex: &sync.Mutex{}, - stackDeletionMutex: &sync.Mutex{}, - Logger: log.New(os.Stderr, "", log.LstdFlags), - } - h.Handle("/{endpointId}/stacks", - bouncer.RestrictedAccess(http.HandlerFunc(h.handlePostStacks))).Methods(http.MethodPost) - h.Handle("/{endpointId}/stacks", - bouncer.RestrictedAccess(http.HandlerFunc(h.handleGetStacks))).Methods(http.MethodGet) - h.Handle("/{endpointId}/stacks/{id}", - bouncer.RestrictedAccess(http.HandlerFunc(h.handleGetStack))).Methods(http.MethodGet) - h.Handle("/{endpointId}/stacks/{id}", - bouncer.RestrictedAccess(http.HandlerFunc(h.handleDeleteStack))).Methods(http.MethodDelete) - h.Handle("/{endpointId}/stacks/{id}", - bouncer.RestrictedAccess(http.HandlerFunc(h.handlePutStack))).Methods(http.MethodPut) - h.Handle("/{endpointId}/stacks/{id}/stackfile", - bouncer.RestrictedAccess(http.HandlerFunc(h.handleGetStackFile))).Methods(http.MethodGet) - return h -} - -type ( - postStacksRequest struct { - Name string `valid:"required"` - SwarmID string `valid:"required"` - StackFileContent string `valid:""` - RepositoryURL string `valid:""` - RepositoryAuthentication bool `valid:""` - RepositoryUsername string `valid:""` - RepositoryPassword string `valid:""` - ComposeFilePathInRepository string `valid:""` - Env []portainer.Pair `valid:""` - } - postStacksResponse struct { - ID string `json:"Id"` - } - getStackFileResponse struct { - StackFileContent string `json:"StackFileContent"` - } - putStackRequest struct { - StackFileContent string `valid:"required"` - Env []portainer.Pair `valid:""` - Prune bool `valid:"-"` - } -) - -// handlePostStacks handles POST requests on /:endpointId/stacks?method= -func (handler *StackHandler) handlePostStacks(w http.ResponseWriter, r *http.Request) { - method := r.FormValue("method") - if method == "" { - httperror.WriteErrorResponse(w, ErrInvalidQueryFormat, http.StatusBadRequest, handler.Logger) - return - } - - if method == "string" { - handler.handlePostStacksStringMethod(w, r) - } else if method == "repository" { - handler.handlePostStacksRepositoryMethod(w, r) - } else if method == "file" { - handler.handlePostStacksFileMethod(w, r) - } else { - httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) - return - } -} - -func (handler *StackHandler) handlePostStacksStringMethod(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - id, err := strconv.Atoi(vars["endpointId"]) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) - return - } - endpointID := portainer.EndpointID(id) - - endpoint, err := handler.EndpointService.Endpoint(endpointID) - if err == portainer.ErrEndpointNotFound { - httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) - return - } else if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - var req postStacksRequest - if err = json.NewDecoder(r.Body).Decode(&req); err != nil { - httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger) - return - } - - _, err = govalidator.ValidateStruct(req) - if err != nil { - httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) - return - } - - stackName := req.Name - if stackName == "" { - httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) - return - } - - stackFileContent := req.StackFileContent - if stackFileContent == "" { - httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) - return - } - - swarmID := req.SwarmID - if swarmID == "" { - httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) - return - } - - stacks, err := handler.StackService.Stacks() - if err != nil && err != portainer.ErrStackNotFound { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - for _, stack := range stacks { - if strings.EqualFold(stack.Name, stackName) { - httperror.WriteErrorResponse(w, portainer.ErrStackAlreadyExists, http.StatusConflict, handler.Logger) - return - } - } - - stack := &portainer.Stack{ - ID: portainer.StackID(stackName + "_" + swarmID), - Name: stackName, - SwarmID: swarmID, - EntryPoint: filesystem.ComposeFileDefaultName, - Env: req.Env, - } - - projectPath, err := handler.FileService.StoreStackFileFromString(string(stack.ID), stack.EntryPoint, stackFileContent) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - stack.ProjectPath = projectPath - - err = handler.StackService.CreateStack(stack) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - securityContext, err := security.RetrieveRestrictedRequestContext(r) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - dockerhub, err := handler.DockerHubService.DockerHub() - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - registries, err := handler.RegistryService.Registries() - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - filteredRegistries, err := security.FilterRegistries(registries, securityContext) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - config := stackDeploymentConfig{ - stack: stack, - endpoint: endpoint, - dockerhub: dockerhub, - registries: filteredRegistries, - prune: false, - } - err = handler.deployStack(&config) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - encodeJSON(w, &postStacksResponse{ID: string(stack.ID)}, handler.Logger) -} - -func (handler *StackHandler) handlePostStacksRepositoryMethod(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - id, err := strconv.Atoi(vars["endpointId"]) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) - return - } - endpointID := portainer.EndpointID(id) - - endpoint, err := handler.EndpointService.Endpoint(endpointID) - if err == portainer.ErrEndpointNotFound { - httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) - return - } else if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - var req postStacksRequest - if err = json.NewDecoder(r.Body).Decode(&req); err != nil { - httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger) - return - } - - _, err = govalidator.ValidateStruct(req) - if err != nil { - httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) - return - } - - stackName := req.Name - swarmID := req.SwarmID - - if stackName == "" || swarmID == "" || req.RepositoryURL == "" { - httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) - return - } - - if req.RepositoryAuthentication && (req.RepositoryUsername == "" || req.RepositoryPassword == "") { - httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) - return - } - - if req.ComposeFilePathInRepository == "" { - req.ComposeFilePathInRepository = filesystem.ComposeFileDefaultName - } - - stacks, err := handler.StackService.Stacks() - if err != nil && err != portainer.ErrStackNotFound { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - for _, stack := range stacks { - if strings.EqualFold(stack.Name, stackName) { - httperror.WriteErrorResponse(w, portainer.ErrStackAlreadyExists, http.StatusConflict, handler.Logger) - return - } - } - - stack := &portainer.Stack{ - ID: portainer.StackID(stackName + "_" + swarmID), - Name: stackName, - SwarmID: swarmID, - EntryPoint: req.ComposeFilePathInRepository, - Env: req.Env, - } - - projectPath := handler.FileService.GetStackProjectPath(string(stack.ID)) - stack.ProjectPath = projectPath - - // Ensure projectPath is empty - err = handler.FileService.RemoveDirectory(projectPath) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - if req.RepositoryAuthentication { - err = handler.GitService.ClonePrivateRepositoryWithBasicAuth(req.RepositoryURL, projectPath, req.RepositoryUsername, req.RepositoryPassword) - } else { - err = handler.GitService.ClonePublicRepository(req.RepositoryURL, projectPath) - } - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - err = handler.StackService.CreateStack(stack) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - securityContext, err := security.RetrieveRestrictedRequestContext(r) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - dockerhub, err := handler.DockerHubService.DockerHub() - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - registries, err := handler.RegistryService.Registries() - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - filteredRegistries, err := security.FilterRegistries(registries, securityContext) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - config := stackDeploymentConfig{ - stack: stack, - endpoint: endpoint, - dockerhub: dockerhub, - registries: filteredRegistries, - prune: false, - } - err = handler.deployStack(&config) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - encodeJSON(w, &postStacksResponse{ID: string(stack.ID)}, handler.Logger) -} - -func (handler *StackHandler) handlePostStacksFileMethod(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - id, err := strconv.Atoi(vars["endpointId"]) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) - return - } - endpointID := portainer.EndpointID(id) - - endpoint, err := handler.EndpointService.Endpoint(endpointID) - if err == portainer.ErrEndpointNotFound { - httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) - return - } else if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - stackName := r.FormValue("Name") - if stackName == "" { - httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) - return - } - - swarmID := r.FormValue("SwarmID") - if swarmID == "" { - httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) - return - } - - envParam := r.FormValue("Env") - var env []portainer.Pair - if err = json.Unmarshal([]byte(envParam), &env); err != nil { - httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) - return - } - - stackFile, _, err := r.FormFile("file") - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - defer stackFile.Close() - - stacks, err := handler.StackService.Stacks() - if err != nil && err != portainer.ErrStackNotFound { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - for _, stack := range stacks { - if strings.EqualFold(stack.Name, stackName) { - httperror.WriteErrorResponse(w, portainer.ErrStackAlreadyExists, http.StatusConflict, handler.Logger) - return - } - } - - stack := &portainer.Stack{ - ID: portainer.StackID(stackName + "_" + swarmID), - Name: stackName, - SwarmID: swarmID, - EntryPoint: filesystem.ComposeFileDefaultName, - Env: env, - } - - projectPath, err := handler.FileService.StoreStackFileFromReader(string(stack.ID), stack.EntryPoint, stackFile) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - stack.ProjectPath = projectPath - - err = handler.StackService.CreateStack(stack) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - securityContext, err := security.RetrieveRestrictedRequestContext(r) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - dockerhub, err := handler.DockerHubService.DockerHub() - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - registries, err := handler.RegistryService.Registries() - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - filteredRegistries, err := security.FilterRegistries(registries, securityContext) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - config := stackDeploymentConfig{ - stack: stack, - endpoint: endpoint, - dockerhub: dockerhub, - registries: filteredRegistries, - prune: false, - } - err = handler.deployStack(&config) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - encodeJSON(w, &postStacksResponse{ID: string(stack.ID)}, handler.Logger) -} - -// handleGetStacks handles GET requests on /:endpointId/stacks?swarmId= -func (handler *StackHandler) handleGetStacks(w http.ResponseWriter, r *http.Request) { - swarmID := r.FormValue("swarmId") - - vars := mux.Vars(r) - - securityContext, err := security.RetrieveRestrictedRequestContext(r) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - id, err := strconv.Atoi(vars["endpointId"]) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) - return - } - endpointID := portainer.EndpointID(id) - - _, err = handler.EndpointService.Endpoint(endpointID) - if err == portainer.ErrEndpointNotFound { - httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) - return - } else if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - var stacks []portainer.Stack - if swarmID == "" { - stacks, err = handler.StackService.Stacks() - } else { - stacks, err = handler.StackService.StacksBySwarmID(swarmID) - } - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - resourceControls, err := handler.ResourceControlService.ResourceControls() - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - filteredStacks := proxy.FilterStacks(stacks, resourceControls, securityContext.IsAdmin, - securityContext.UserID, securityContext.UserMemberships) - - encodeJSON(w, filteredStacks, handler.Logger) -} - -// handleGetStack handles GET requests on /:endpointId/stacks/:id -func (handler *StackHandler) handleGetStack(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - stackID := vars["id"] - - securityContext, err := security.RetrieveRestrictedRequestContext(r) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - endpointID, err := strconv.Atoi(vars["endpointId"]) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) - return - } - - _, err = handler.EndpointService.Endpoint(portainer.EndpointID(endpointID)) - if err == portainer.ErrEndpointNotFound { - httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) - return - } else if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - stack, err := handler.StackService.Stack(portainer.StackID(stackID)) - if err == portainer.ErrStackNotFound { - httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) - return - } else if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - resourceControl, err := handler.ResourceControlService.ResourceControlByResourceID(stack.Name) - if err != nil && err != portainer.ErrResourceControlNotFound { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - extendedStack := proxy.ExtendedStack{*stack, portainer.ResourceControl{}} - if resourceControl != nil { - if securityContext.IsAdmin || proxy.CanAccessStack(stack, resourceControl, securityContext.UserID, securityContext.UserMemberships) { - extendedStack.ResourceControl = *resourceControl - } else { - httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, handler.Logger) - return - } - } - - encodeJSON(w, extendedStack, handler.Logger) -} - -// handlePutStack handles PUT requests on /:endpointId/stacks/:id -func (handler *StackHandler) handlePutStack(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - stackID := vars["id"] - - endpointID, err := strconv.Atoi(vars["endpointId"]) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) - return - } - - endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID)) - if err == portainer.ErrEndpointNotFound { - httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) - return - } else if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - stack, err := handler.StackService.Stack(portainer.StackID(stackID)) - if err == portainer.ErrStackNotFound { - httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) - return - } else if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - var req putStackRequest - if err = json.NewDecoder(r.Body).Decode(&req); err != nil { - httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger) - return - } - - _, err = govalidator.ValidateStruct(req) - if err != nil { - httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) - return - } - stack.Env = req.Env - - _, err = handler.FileService.StoreStackFileFromString(string(stack.ID), stack.EntryPoint, req.StackFileContent) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - err = handler.StackService.UpdateStack(stack.ID, stack) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - securityContext, err := security.RetrieveRestrictedRequestContext(r) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - dockerhub, err := handler.DockerHubService.DockerHub() - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - registries, err := handler.RegistryService.Registries() - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - filteredRegistries, err := security.FilterRegistries(registries, securityContext) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - config := stackDeploymentConfig{ - stack: stack, - endpoint: endpoint, - dockerhub: dockerhub, - registries: filteredRegistries, - prune: req.Prune, - } - err = handler.deployStack(&config) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } -} - -// handleGetStackFile handles GET requests on /:endpointId/stacks/:id/stackfile -func (handler *StackHandler) handleGetStackFile(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - stackID := vars["id"] - - endpointID, err := strconv.Atoi(vars["endpointId"]) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) - return - } - - _, err = handler.EndpointService.Endpoint(portainer.EndpointID(endpointID)) - if err == portainer.ErrEndpointNotFound { - httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) - return - } else if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - stack, err := handler.StackService.Stack(portainer.StackID(stackID)) - if err == portainer.ErrStackNotFound { - httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) - return - } else if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - stackFileContent, err := handler.FileService.GetFileContent(path.Join(stack.ProjectPath, stack.EntryPoint)) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) - return - } - - encodeJSON(w, &getStackFileResponse{StackFileContent: stackFileContent}, handler.Logger) -} - -// handleDeleteStack handles DELETE requests on /:endpointId/stacks/:id -func (handler *StackHandler) handleDeleteStack(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - stackID := vars["id"] - - endpointID, err := strconv.Atoi(vars["endpointId"]) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) - return - } - - endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID)) - if err == portainer.ErrEndpointNotFound { - httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) - return - } else if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - stack, err := handler.StackService.Stack(portainer.StackID(stackID)) - if err == portainer.ErrStackNotFound { - httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) - return - } else if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - handler.stackDeletionMutex.Lock() - err = handler.StackManager.Remove(stack, endpoint) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - handler.stackDeletionMutex.Unlock() - - err = handler.StackService.DeleteStack(portainer.StackID(stackID)) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - err = handler.FileService.RemoveDirectory(stack.ProjectPath) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } -} - -func (handler *StackHandler) deployStack(config *stackDeploymentConfig) error { - handler.stackCreationMutex.Lock() - - handler.StackManager.Login(config.dockerhub, config.registries, config.endpoint) - - err := handler.StackManager.Deploy(config.stack, config.prune, config.endpoint) - if err != nil { - handler.stackCreationMutex.Unlock() - return err - } - - err = handler.StackManager.Logout(config.endpoint) - if err != nil { - handler.stackCreationMutex.Unlock() - return err - } - - handler.stackCreationMutex.Unlock() - return nil -} diff --git a/api/http/handler/stacks/create_compose_stack.go b/api/http/handler/stacks/create_compose_stack.go new file mode 100644 index 000000000..c1321e297 --- /dev/null +++ b/api/http/handler/stacks/create_compose_stack.go @@ -0,0 +1,302 @@ +package stacks + +import ( + "net/http" + "strings" + + "github.com/asaskevich/govalidator" + "github.com/portainer/portainer" + "github.com/portainer/portainer/filesystem" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" + "github.com/portainer/portainer/http/security" +) + +type composeStackFromFileContentPayload struct { + Name string + StackFileContent string +} + +func (payload *composeStackFromFileContentPayload) Validate(r *http.Request) error { + if govalidator.IsNull(payload.Name) { + return portainer.Error("Invalid stack name") + } + if govalidator.IsNull(payload.StackFileContent) { + return portainer.Error("Invalid stack file content") + } + return nil +} + +func (handler *Handler) createComposeStackFromFileContent(w http.ResponseWriter, r *http.Request, endpoint *portainer.Endpoint) *httperror.HandlerError { + var payload composeStackFromFileContentPayload + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + stacks, err := handler.StackService.Stacks() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve stacks from the database", err} + } + + for _, stack := range stacks { + if strings.EqualFold(stack.Name, payload.Name) { + return &httperror.HandlerError{http.StatusConflict, "A stack with this name already exists", portainer.ErrStackAlreadyExists} + } + } + + stackIdentifier := buildStackIdentifier(payload.Name, endpoint.ID) + stack := &portainer.Stack{ + ID: portainer.StackID(stackIdentifier), + Name: payload.Name, + Type: portainer.DockerComposeStack, + EndpointID: endpoint.ID, + EntryPoint: filesystem.ComposeFileDefaultName, + } + + projectPath, err := handler.FileService.StoreStackFileFromBytes(string(stack.ID), stack.EntryPoint, []byte(payload.StackFileContent)) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist Compose file on disk", err} + } + stack.ProjectPath = projectPath + + doCleanUp := true + defer handler.cleanUp(stack, &doCleanUp) + + config, configErr := handler.createComposeDeployConfig(r, stack, endpoint) + if configErr != nil { + return configErr + } + + err = handler.deployComposeStack(config) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, err.Error(), err} + } + + err = handler.StackService.CreateStack(stack) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the stack inside the database", err} + } + + doCleanUp = false + return response.JSON(w, stack) +} + +type composeStackFromGitRepositoryPayload struct { + Name string + RepositoryURL string + RepositoryAuthentication bool + RepositoryUsername string + RepositoryPassword string + ComposeFilePathInRepository string +} + +func (payload *composeStackFromGitRepositoryPayload) Validate(r *http.Request) error { + if govalidator.IsNull(payload.Name) { + return portainer.Error("Invalid stack name") + } + if govalidator.IsNull(payload.RepositoryURL) || !govalidator.IsURL(payload.RepositoryURL) { + return portainer.Error("Invalid repository URL. Must correspond to a valid URL format") + } + if payload.RepositoryAuthentication && (govalidator.IsNull(payload.RepositoryUsername) || govalidator.IsNull(payload.RepositoryPassword)) { + return portainer.Error("Invalid repository credentials. Username and password must be specified when authentication is enabled") + } + if govalidator.IsNull(payload.ComposeFilePathInRepository) { + payload.ComposeFilePathInRepository = filesystem.ComposeFileDefaultName + } + return nil +} + +func (handler *Handler) createComposeStackFromGitRepository(w http.ResponseWriter, r *http.Request, endpoint *portainer.Endpoint) *httperror.HandlerError { + var payload composeStackFromGitRepositoryPayload + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + stacks, err := handler.StackService.Stacks() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve stacks from the database", err} + } + + for _, stack := range stacks { + if strings.EqualFold(stack.Name, payload.Name) { + return &httperror.HandlerError{http.StatusConflict, "A stack with this name already exists", portainer.ErrStackAlreadyExists} + } + } + + stackIdentifier := buildStackIdentifier(payload.Name, endpoint.ID) + stack := &portainer.Stack{ + ID: portainer.StackID(stackIdentifier), + Name: payload.Name, + Type: portainer.DockerComposeStack, + EndpointID: endpoint.ID, + EntryPoint: payload.ComposeFilePathInRepository, + } + + projectPath := handler.FileService.GetStackProjectPath(string(stack.ID)) + stack.ProjectPath = projectPath + + gitCloneParams := &cloneRepositoryParameters{ + url: payload.RepositoryURL, + path: projectPath, + authentication: payload.RepositoryAuthentication, + username: payload.RepositoryUsername, + password: payload.RepositoryPassword, + } + err = handler.cloneGitRepository(gitCloneParams) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to clone git repository", err} + } + + doCleanUp := true + defer handler.cleanUp(stack, &doCleanUp) + + config, configErr := handler.createComposeDeployConfig(r, stack, endpoint) + if configErr != nil { + return configErr + } + + err = handler.deployComposeStack(config) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, err.Error(), err} + } + + err = handler.StackService.CreateStack(stack) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the stack inside the database", err} + } + + doCleanUp = false + return response.JSON(w, stack) +} + +type composeStackFromFileUploadPayload struct { + Name string + StackFileContent []byte +} + +func (payload *composeStackFromFileUploadPayload) Validate(r *http.Request) error { + name, err := request.RetrieveMultiPartFormValue(r, "Name", false) + if err != nil { + return portainer.Error("Invalid stack name") + } + payload.Name = name + + composeFileContent, err := request.RetrieveMultiPartFormFile(r, "file") + if err != nil { + return portainer.Error("Invalid Compose file. Ensure that the Compose file is uploaded correctly") + } + payload.StackFileContent = composeFileContent + + return nil +} + +func (handler *Handler) createComposeStackFromFileUpload(w http.ResponseWriter, r *http.Request, endpoint *portainer.Endpoint) *httperror.HandlerError { + payload := &composeStackFromFileUploadPayload{} + err := payload.Validate(r) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + stacks, err := handler.StackService.Stacks() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve stacks from the database", err} + } + + for _, stack := range stacks { + if strings.EqualFold(stack.Name, payload.Name) { + return &httperror.HandlerError{http.StatusConflict, "A stack with this name already exists", portainer.ErrStackAlreadyExists} + } + } + + stackIdentifier := buildStackIdentifier(payload.Name, endpoint.ID) + stack := &portainer.Stack{ + ID: portainer.StackID(stackIdentifier), + Name: payload.Name, + Type: portainer.DockerComposeStack, + EndpointID: endpoint.ID, + EntryPoint: filesystem.ComposeFileDefaultName, + } + + projectPath, err := handler.FileService.StoreStackFileFromBytes(string(stack.ID), stack.EntryPoint, []byte(payload.StackFileContent)) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist Compose file on disk", err} + } + stack.ProjectPath = projectPath + + doCleanUp := true + defer handler.cleanUp(stack, &doCleanUp) + + config, configErr := handler.createComposeDeployConfig(r, stack, endpoint) + if configErr != nil { + return configErr + } + + err = handler.deployComposeStack(config) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, err.Error(), err} + } + + err = handler.StackService.CreateStack(stack) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the stack inside the database", err} + } + + doCleanUp = false + return response.JSON(w, stack) +} + +type composeStackDeploymentConfig struct { + stack *portainer.Stack + endpoint *portainer.Endpoint + dockerhub *portainer.DockerHub + registries []portainer.Registry +} + +func (handler *Handler) createComposeDeployConfig(r *http.Request, stack *portainer.Stack, endpoint *portainer.Endpoint) (*composeStackDeploymentConfig, *httperror.HandlerError) { + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} + } + + dockerhub, err := handler.DockerHubService.DockerHub() + if err != nil { + return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve DockerHub details from the database", err} + } + + registries, err := handler.RegistryService.Registries() + if err != nil { + return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve registries from the database", err} + } + filteredRegistries := security.FilterRegistries(registries, securityContext) + + config := &composeStackDeploymentConfig{ + stack: stack, + endpoint: endpoint, + dockerhub: dockerhub, + registries: filteredRegistries, + } + + return config, nil +} + +// TODO: libcompose uses credentials store into a config.json file to pull images from +// private registries. Right now the only solution is to re-use the embedded Docker binary +// to login/logout, which will generate the required data in the config.json file and then +// clean it. Hence the use of the mutex. +// We should contribute to libcompose to support authentication without using the config.json file. +func (handler *Handler) deployComposeStack(config *composeStackDeploymentConfig) error { + handler.stackCreationMutex.Lock() + defer handler.stackCreationMutex.Unlock() + + handler.SwarmStackManager.Login(config.dockerhub, config.registries, config.endpoint) + + err := handler.ComposeStackManager.Up(config.stack, config.endpoint) + if err != nil { + return err + } + + return handler.SwarmStackManager.Logout(config.endpoint) +} diff --git a/api/http/handler/stacks/create_swarm_stack.go b/api/http/handler/stacks/create_swarm_stack.go new file mode 100644 index 000000000..0a87fb53f --- /dev/null +++ b/api/http/handler/stacks/create_swarm_stack.go @@ -0,0 +1,334 @@ +package stacks + +import ( + "net/http" + "strings" + + "github.com/asaskevich/govalidator" + "github.com/portainer/portainer" + "github.com/portainer/portainer/filesystem" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" + "github.com/portainer/portainer/http/security" +) + +type swarmStackFromFileContentPayload struct { + Name string + SwarmID string + StackFileContent string + Env []portainer.Pair +} + +func (payload *swarmStackFromFileContentPayload) Validate(r *http.Request) error { + if govalidator.IsNull(payload.Name) { + return portainer.Error("Invalid stack name") + } + if govalidator.IsNull(payload.SwarmID) { + return portainer.Error("Invalid Swarm ID") + } + if govalidator.IsNull(payload.StackFileContent) { + return portainer.Error("Invalid stack file content") + } + return nil +} + +func (handler *Handler) createSwarmStackFromFileContent(w http.ResponseWriter, r *http.Request, endpoint *portainer.Endpoint) *httperror.HandlerError { + var payload swarmStackFromFileContentPayload + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + stacks, err := handler.StackService.Stacks() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve stacks from the database", err} + } + + for _, stack := range stacks { + if strings.EqualFold(stack.Name, payload.Name) { + return &httperror.HandlerError{http.StatusConflict, "A stack with this name already exists", portainer.ErrStackAlreadyExists} + } + } + + stackIdentifier := buildStackIdentifier(payload.Name, endpoint.ID) + stack := &portainer.Stack{ + ID: portainer.StackID(stackIdentifier), + Name: payload.Name, + Type: portainer.DockerSwarmStack, + SwarmID: payload.SwarmID, + EndpointID: endpoint.ID, + EntryPoint: filesystem.ComposeFileDefaultName, + Env: payload.Env, + } + + projectPath, err := handler.FileService.StoreStackFileFromBytes(string(stack.ID), stack.EntryPoint, []byte(payload.StackFileContent)) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist Compose file on disk", err} + } + stack.ProjectPath = projectPath + + doCleanUp := true + defer handler.cleanUp(stack, &doCleanUp) + + config, configErr := handler.createSwarmDeployConfig(r, stack, endpoint, false) + if configErr != nil { + return configErr + } + + err = handler.deploySwarmStack(config) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, err.Error(), err} + } + + err = handler.StackService.CreateStack(stack) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the stack inside the database", err} + } + + doCleanUp = false + return response.JSON(w, stack) +} + +type swarmStackFromGitRepositoryPayload struct { + Name string + SwarmID string + Env []portainer.Pair + RepositoryURL string + RepositoryAuthentication bool + RepositoryUsername string + RepositoryPassword string + ComposeFilePathInRepository string +} + +func (payload *swarmStackFromGitRepositoryPayload) Validate(r *http.Request) error { + if govalidator.IsNull(payload.Name) { + return portainer.Error("Invalid stack name") + } + if govalidator.IsNull(payload.SwarmID) { + return portainer.Error("Invalid Swarm ID") + } + if govalidator.IsNull(payload.RepositoryURL) || !govalidator.IsURL(payload.RepositoryURL) { + return portainer.Error("Invalid repository URL. Must correspond to a valid URL format") + } + if payload.RepositoryAuthentication && (govalidator.IsNull(payload.RepositoryUsername) || govalidator.IsNull(payload.RepositoryPassword)) { + return portainer.Error("Invalid repository credentials. Username and password must be specified when authentication is enabled") + } + if govalidator.IsNull(payload.ComposeFilePathInRepository) { + payload.ComposeFilePathInRepository = filesystem.ComposeFileDefaultName + } + return nil +} + +func (handler *Handler) createSwarmStackFromGitRepository(w http.ResponseWriter, r *http.Request, endpoint *portainer.Endpoint) *httperror.HandlerError { + var payload swarmStackFromGitRepositoryPayload + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + stacks, err := handler.StackService.Stacks() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve stacks from the database", err} + } + + for _, stack := range stacks { + if strings.EqualFold(stack.Name, payload.Name) { + return &httperror.HandlerError{http.StatusConflict, "A stack with this name already exists", portainer.ErrStackAlreadyExists} + } + } + + stackIdentifier := buildStackIdentifier(payload.Name, endpoint.ID) + stack := &portainer.Stack{ + ID: portainer.StackID(stackIdentifier), + Name: payload.Name, + Type: portainer.DockerSwarmStack, + SwarmID: payload.SwarmID, + EndpointID: endpoint.ID, + EntryPoint: payload.ComposeFilePathInRepository, + Env: payload.Env, + } + + projectPath := handler.FileService.GetStackProjectPath(string(stack.ID)) + stack.ProjectPath = projectPath + + gitCloneParams := &cloneRepositoryParameters{ + url: payload.RepositoryURL, + path: projectPath, + authentication: payload.RepositoryAuthentication, + username: payload.RepositoryUsername, + password: payload.RepositoryPassword, + } + err = handler.cloneGitRepository(gitCloneParams) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to clone git repository", err} + } + + doCleanUp := true + defer handler.cleanUp(stack, &doCleanUp) + + config, configErr := handler.createSwarmDeployConfig(r, stack, endpoint, false) + if configErr != nil { + return configErr + } + + err = handler.deploySwarmStack(config) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, err.Error(), err} + } + + err = handler.StackService.CreateStack(stack) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the stack inside the database", err} + } + + doCleanUp = false + return response.JSON(w, stack) +} + +type swarmStackFromFileUploadPayload struct { + Name string + SwarmID string + StackFileContent []byte + Env []portainer.Pair +} + +func (payload *swarmStackFromFileUploadPayload) Validate(r *http.Request) error { + name, err := request.RetrieveMultiPartFormValue(r, "Name", false) + if err != nil { + return portainer.Error("Invalid stack name") + } + payload.Name = name + + swarmID, err := request.RetrieveMultiPartFormValue(r, "SwarmID", false) + if err != nil { + return portainer.Error("Invalid Swarm ID") + } + payload.SwarmID = swarmID + + composeFileContent, err := request.RetrieveMultiPartFormFile(r, "file") + if err != nil { + return portainer.Error("Invalid Compose file. Ensure that the Compose file is uploaded correctly") + } + payload.StackFileContent = composeFileContent + + var env []portainer.Pair + err = request.RetrieveMultiPartFormJSONValue(r, "Env", &env, true) + if err != nil { + return portainer.Error("Invalid Env parameter") + } + payload.Env = env + return nil +} + +func (handler *Handler) createSwarmStackFromFileUpload(w http.ResponseWriter, r *http.Request, endpoint *portainer.Endpoint) *httperror.HandlerError { + payload := &swarmStackFromFileUploadPayload{} + err := payload.Validate(r) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + stacks, err := handler.StackService.Stacks() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve stacks from the database", err} + } + + for _, stack := range stacks { + if strings.EqualFold(stack.Name, payload.Name) { + return &httperror.HandlerError{http.StatusConflict, "A stack with this name already exists", portainer.ErrStackAlreadyExists} + } + } + + stackIdentifier := buildStackIdentifier(payload.Name, endpoint.ID) + stack := &portainer.Stack{ + ID: portainer.StackID(stackIdentifier), + Name: payload.Name, + Type: portainer.DockerSwarmStack, + SwarmID: payload.SwarmID, + EndpointID: endpoint.ID, + EntryPoint: filesystem.ComposeFileDefaultName, + Env: payload.Env, + } + + projectPath, err := handler.FileService.StoreStackFileFromBytes(string(stack.ID), stack.EntryPoint, []byte(payload.StackFileContent)) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist Compose file on disk", err} + } + stack.ProjectPath = projectPath + + doCleanUp := true + defer handler.cleanUp(stack, &doCleanUp) + + config, configErr := handler.createSwarmDeployConfig(r, stack, endpoint, false) + if configErr != nil { + return configErr + } + + err = handler.deploySwarmStack(config) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, err.Error(), err} + } + + err = handler.StackService.CreateStack(stack) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the stack inside the database", err} + } + + doCleanUp = false + return response.JSON(w, stack) +} + +type swarmStackDeploymentConfig struct { + stack *portainer.Stack + endpoint *portainer.Endpoint + dockerhub *portainer.DockerHub + registries []portainer.Registry + prune bool +} + +func (handler *Handler) createSwarmDeployConfig(r *http.Request, stack *portainer.Stack, endpoint *portainer.Endpoint, prune bool) (*swarmStackDeploymentConfig, *httperror.HandlerError) { + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} + } + + dockerhub, err := handler.DockerHubService.DockerHub() + if err != nil { + return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve DockerHub details from the database", err} + } + + registries, err := handler.RegistryService.Registries() + if err != nil { + return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve registries from the database", err} + } + filteredRegistries := security.FilterRegistries(registries, securityContext) + + config := &swarmStackDeploymentConfig{ + stack: stack, + endpoint: endpoint, + dockerhub: dockerhub, + registries: filteredRegistries, + prune: prune, + } + + return config, nil +} + +func (handler *Handler) deploySwarmStack(config *swarmStackDeploymentConfig) error { + handler.stackCreationMutex.Lock() + defer handler.stackCreationMutex.Unlock() + + handler.SwarmStackManager.Login(config.dockerhub, config.registries, config.endpoint) + + err := handler.SwarmStackManager.Deploy(config.stack, config.prune, config.endpoint) + if err != nil { + return err + } + + err = handler.SwarmStackManager.Logout(config.endpoint) + if err != nil { + return err + } + + return nil +} diff --git a/api/http/handler/stacks/git.go b/api/http/handler/stacks/git.go new file mode 100644 index 000000000..1ac62a443 --- /dev/null +++ b/api/http/handler/stacks/git.go @@ -0,0 +1,16 @@ +package stacks + +type cloneRepositoryParameters struct { + url string + path string + authentication bool + username string + password string +} + +func (handler *Handler) cloneGitRepository(parameters *cloneRepositoryParameters) error { + if parameters.authentication { + return handler.GitService.ClonePrivateRepositoryWithBasicAuth(parameters.url, parameters.path, parameters.username, parameters.password) + } + return handler.GitService.ClonePublicRepository(parameters.url, parameters.path) +} diff --git a/api/http/handler/stacks/handler.go b/api/http/handler/stacks/handler.go new file mode 100644 index 000000000..9ce1bab17 --- /dev/null +++ b/api/http/handler/stacks/handler.go @@ -0,0 +1,69 @@ +package stacks + +import ( + "net/http" + "sync" + + "github.com/gorilla/mux" + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/security" +) + +// Handler is the HTTP handler used to handle stack operations. +type Handler struct { + stackCreationMutex *sync.Mutex + stackDeletionMutex *sync.Mutex + *mux.Router + FileService portainer.FileService + GitService portainer.GitService + StackService portainer.StackService + EndpointService portainer.EndpointService + EndpointGroupService portainer.EndpointGroupService + TeamMembershipService portainer.TeamMembershipService + ResourceControlService portainer.ResourceControlService + RegistryService portainer.RegistryService + DockerHubService portainer.DockerHubService + SwarmStackManager portainer.SwarmStackManager + ComposeStackManager portainer.ComposeStackManager +} + +// NewHandler creates a handler to manage stack operations. +func NewHandler(bouncer *security.RequestBouncer) *Handler { + h := &Handler{ + Router: mux.NewRouter(), + stackCreationMutex: &sync.Mutex{}, + stackDeletionMutex: &sync.Mutex{}, + } + h.Handle("/stacks", + bouncer.RestrictedAccess(httperror.LoggerHandler(h.stackCreate))).Methods(http.MethodPost) + h.Handle("/stacks", + bouncer.RestrictedAccess(httperror.LoggerHandler(h.stackList))).Methods(http.MethodGet) + h.Handle("/stacks/{id}", + bouncer.RestrictedAccess(httperror.LoggerHandler(h.stackInspect))).Methods(http.MethodGet) + h.Handle("/stacks/{id}", + bouncer.RestrictedAccess(httperror.LoggerHandler(h.stackDelete))).Methods(http.MethodDelete) + h.Handle("/stacks/{id}", + bouncer.RestrictedAccess(httperror.LoggerHandler(h.stackUpdate))).Methods(http.MethodPut) + h.Handle("/stacks/{id}/file", + bouncer.RestrictedAccess(httperror.LoggerHandler(h.stackFile))).Methods(http.MethodGet) + return h +} + +func (handler *Handler) checkEndpointAccess(endpoint *portainer.Endpoint, userID portainer.UserID) error { + memberships, err := handler.TeamMembershipService.TeamMembershipsByUserID(userID) + if err != nil { + return err + } + + group, err := handler.EndpointGroupService.EndpointGroup(endpoint.GroupID) + if err != nil { + return err + } + + if !security.AuthorizedEndpointAccess(endpoint, group, userID, memberships) { + return portainer.ErrEndpointAccessDenied + } + + return nil +} diff --git a/api/http/handler/stacks/stack_create.go b/api/http/handler/stacks/stack_create.go new file mode 100644 index 000000000..dac491f69 --- /dev/null +++ b/api/http/handler/stacks/stack_create.go @@ -0,0 +1,99 @@ +package stacks + +import ( + "net/http" + "strconv" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/security" +) + +func (handler *Handler) cleanUp(stack *portainer.Stack, doCleanUp *bool) error { + if !*doCleanUp { + return nil + } + + handler.FileService.RemoveDirectory(stack.ProjectPath) + return nil +} + +func buildStackIdentifier(stackName string, endpointID portainer.EndpointID) string { + return stackName + "_" + strconv.Itoa(int(endpointID)) +} + +// POST request on /api/stacks?type=&method=&endpointId= +func (handler *Handler) stackCreate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + stackType, err := request.RetrieveNumericQueryParameter(r, "type", false) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: type", err} + } + + method, err := request.RetrieveQueryParameter(r, "method", false) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: method", err} + } + + endpointID, err := request.RetrieveNumericQueryParameter(r, "endpointId", false) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: endpointId", err} + } + + endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID)) + if err == portainer.ErrEndpointNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err} + } + + tokenData, err := security.RetrieveTokenData(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve user authentication token", err} + } + + if tokenData.Role != portainer.AdministratorRole { + err = handler.checkEndpointAccess(endpoint, tokenData.ID) + if err != nil && err == portainer.ErrEndpointAccessDenied { + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", portainer.ErrEndpointAccessDenied} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to verify permission to access endpoint", err} + } + } + + switch portainer.StackType(stackType) { + case portainer.DockerSwarmStack: + return handler.createSwarmStack(w, r, method, endpoint) + case portainer.DockerComposeStack: + return handler.createComposeStack(w, r, method, endpoint) + } + + return &httperror.HandlerError{http.StatusBadRequest, "Invalid value for query parameter: type. Value must be one of: 1 (Swarm stack) or 2 (Compose stack)", request.ErrInvalidQueryParameter} +} + +func (handler *Handler) createComposeStack(w http.ResponseWriter, r *http.Request, method string, endpoint *portainer.Endpoint) *httperror.HandlerError { + + switch method { + case "string": + return handler.createComposeStackFromFileContent(w, r, endpoint) + case "repository": + return handler.createComposeStackFromGitRepository(w, r, endpoint) + case "file": + return handler.createComposeStackFromFileUpload(w, r, endpoint) + } + + return &httperror.HandlerError{http.StatusBadRequest, "Invalid value for query parameter: method. Value must be one of: string, repository or file", request.ErrInvalidQueryParameter} +} + +func (handler *Handler) createSwarmStack(w http.ResponseWriter, r *http.Request, method string, endpoint *portainer.Endpoint) *httperror.HandlerError { + switch method { + case "string": + return handler.createSwarmStackFromFileContent(w, r, endpoint) + case "repository": + return handler.createSwarmStackFromGitRepository(w, r, endpoint) + case "file": + return handler.createSwarmStackFromFileUpload(w, r, endpoint) + } + + return &httperror.HandlerError{http.StatusBadRequest, "Invalid value for query parameter: method. Value must be one of: string, repository or file", request.ErrInvalidQueryParameter} +} diff --git a/api/http/handler/stacks/stack_delete.go b/api/http/handler/stacks/stack_delete.go new file mode 100644 index 000000000..4deed5612 --- /dev/null +++ b/api/http/handler/stacks/stack_delete.go @@ -0,0 +1,127 @@ +package stacks + +import ( + "net/http" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/proxy" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" + "github.com/portainer/portainer/http/security" +) + +// DELETE request on /api/stacks/:id?external=&endpointId= +func (handler *Handler) stackDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + stackID, err := request.RetrieveRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid stack identifier route variable", err} + } + + externalStack, _ := request.RetrieveBooleanQueryParameter(r, "external", true) + if externalStack { + return handler.deleteExternalStack(r, w, stackID) + } + + stack, err := handler.StackService.Stack(portainer.StackID(stackID)) + if err == portainer.ErrStackNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find a stack with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a stack with the specified identifier inside the database", err} + } + + resourceControl, err := handler.ResourceControlService.ResourceControlByResourceID(stack.Name) + if err != nil && err != portainer.ErrResourceControlNotFound { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a resource control associated to the stack", err} + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} + } + + if resourceControl != nil { + if !securityContext.IsAdmin && !proxy.CanAccessStack(stack, resourceControl, securityContext.UserID, securityContext.UserMemberships) { + return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", portainer.ErrResourceAccessDenied} + } + } + + endpoint, err := handler.EndpointService.Endpoint(stack.EndpointID) + if err == portainer.ErrEndpointNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find the endpoint associated to the stack inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find the endpoint associated to the stack inside the database", err} + } + + err = handler.deleteStack(stack, endpoint) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, err.Error(), err} + } + + err = handler.StackService.DeleteStack(portainer.StackID(stackID)) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove the stack from the database", err} + } + + err = handler.FileService.RemoveDirectory(stack.ProjectPath) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove stack files from disk", err} + } + + return response.Empty(w) +} + +func (handler *Handler) deleteExternalStack(r *http.Request, w http.ResponseWriter, stackName string) *httperror.HandlerError { + stack, err := handler.StackService.StackByName(stackName) + if err != nil && err != portainer.ErrStackNotFound { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to check for stack existence inside the database", err} + } + if stack != nil { + return &httperror.HandlerError{http.StatusBadRequest, "A stack with this name exists inside the database. Cannot use external delete method", portainer.ErrStackNotExternal} + } + + endpointID, err := request.RetrieveNumericQueryParameter(r, "endpointId", false) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: endpointId", err} + } + + endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID)) + if err == portainer.ErrEndpointNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find the endpoint associated to the stack inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find the endpoint associated to the stack inside the database", err} + } + + tokenData, err := security.RetrieveTokenData(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve user authentication token", err} + } + + if tokenData.Role != portainer.AdministratorRole { + err = handler.checkEndpointAccess(endpoint, tokenData.ID) + if err != nil && err == portainer.ErrEndpointAccessDenied { + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", portainer.ErrEndpointAccessDenied} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to verify permission to access endpoint", err} + } + } + + stack = &portainer.Stack{ + Name: stackName, + Type: portainer.DockerSwarmStack, + } + + err = handler.deleteStack(stack, endpoint) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to delete stack", err} + } + + return response.Empty(w) +} + +func (handler *Handler) deleteStack(stack *portainer.Stack, endpoint *portainer.Endpoint) error { + 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_file.go b/api/http/handler/stacks/stack_file.go new file mode 100644 index 000000000..b476a2e47 --- /dev/null +++ b/api/http/handler/stacks/stack_file.go @@ -0,0 +1,58 @@ +package stacks + +import ( + "net/http" + "path" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/proxy" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" + "github.com/portainer/portainer/http/security" +) + +type stackFileResponse struct { + StackFileContent string `json:"StackFileContent"` +} + +// GET request on /api/stacks/:id/file +func (handler *Handler) stackFile(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + stackID, err := request.RetrieveRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid stack identifier route variable", err} + } + + stack, err := handler.StackService.Stack(portainer.StackID(stackID)) + if err == portainer.ErrStackNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find a stack with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a stack with the specified identifier inside the database", err} + } + + resourceControl, err := handler.ResourceControlService.ResourceControlByResourceID(stack.Name) + if err != nil && err != portainer.ErrResourceControlNotFound { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a resource control associated to the stack", err} + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} + } + + extendedStack := proxy.ExtendedStack{*stack, portainer.ResourceControl{}} + if resourceControl != nil { + if securityContext.IsAdmin || proxy.CanAccessStack(stack, resourceControl, securityContext.UserID, securityContext.UserMemberships) { + extendedStack.ResourceControl = *resourceControl + } else { + return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", portainer.ErrResourceAccessDenied} + } + } + + stackFileContent, err := handler.FileService.GetFileContent(path.Join(stack.ProjectPath, stack.EntryPoint)) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve Compose file from disk", err} + } + + return response.JSON(w, &stackFileResponse{StackFileContent: stackFileContent}) +} diff --git a/api/http/handler/stacks/stack_inspect.go b/api/http/handler/stacks/stack_inspect.go new file mode 100644 index 000000000..66a94a601 --- /dev/null +++ b/api/http/handler/stacks/stack_inspect.go @@ -0,0 +1,48 @@ +package stacks + +import ( + "net/http" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/proxy" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" + "github.com/portainer/portainer/http/security" +) + +// GET request on /api/stacks/:id +func (handler *Handler) stackInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + stackID, err := request.RetrieveRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid stack identifier route variable", err} + } + + stack, err := handler.StackService.Stack(portainer.StackID(stackID)) + if err == portainer.ErrStackNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find a stack with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a stack with the specified identifier inside the database", err} + } + + resourceControl, err := handler.ResourceControlService.ResourceControlByResourceID(stack.Name) + if err != nil && err != portainer.ErrResourceControlNotFound { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a resource control associated to the stack", err} + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} + } + + extendedStack := proxy.ExtendedStack{*stack, portainer.ResourceControl{}} + if resourceControl != nil { + if securityContext.IsAdmin || proxy.CanAccessStack(stack, resourceControl, securityContext.UserID, securityContext.UserMemberships) { + extendedStack.ResourceControl = *resourceControl + } else { + return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", portainer.ErrResourceAccessDenied} + } + } + + return response.JSON(w, extendedStack) +} diff --git a/api/http/handler/stacks/stack_list.go b/api/http/handler/stacks/stack_list.go new file mode 100644 index 000000000..fc4732e06 --- /dev/null +++ b/api/http/handler/stacks/stack_list.go @@ -0,0 +1,65 @@ +package stacks + +import ( + "net/http" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/proxy" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" + "github.com/portainer/portainer/http/security" +) + +type stackListOperationFilters struct { + SwarmID string `json:"SwarmID"` + EndpointID int `json:"EndpointID"` +} + +// GET request on /api/stacks?(filters=) +func (handler *Handler) stackList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + var filters stackListOperationFilters + err := request.RetrieveJSONQueryParameter(r, "filters", &filters, true) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: filters", err} + } + + stacks, err := handler.StackService.Stacks() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve stacks from the database", err} + } + stacks = filterStacks(stacks, &filters) + + resourceControls, err := handler.ResourceControlService.ResourceControls() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve resource controls from the database", err} + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} + } + + filteredStacks := proxy.FilterStacks(stacks, resourceControls, securityContext.IsAdmin, + securityContext.UserID, securityContext.UserMemberships) + + return response.JSON(w, filteredStacks) +} + +func filterStacks(stacks []portainer.Stack, filters *stackListOperationFilters) []portainer.Stack { + if filters.EndpointID == 0 && filters.SwarmID == "" { + return stacks + } + + filteredStacks := make([]portainer.Stack, 0, len(stacks)) + for _, stack := range stacks { + if stack.Type == portainer.DockerComposeStack && stack.EndpointID == portainer.EndpointID(filters.EndpointID) { + filteredStacks = append(filteredStacks, stack) + } + if stack.Type == portainer.DockerSwarmStack && stack.SwarmID == filters.SwarmID { + filteredStacks = append(filteredStacks, stack) + } + } + + return filteredStacks +} diff --git a/api/http/handler/stacks/stack_update.go b/api/http/handler/stacks/stack_update.go new file mode 100644 index 000000000..98c25d60f --- /dev/null +++ b/api/http/handler/stacks/stack_update.go @@ -0,0 +1,146 @@ +package stacks + +import ( + "net/http" + + "github.com/asaskevich/govalidator" + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/proxy" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" + "github.com/portainer/portainer/http/security" +) + +type updateComposeStackPayload struct { + StackFileContent string +} + +func (payload *updateComposeStackPayload) Validate(r *http.Request) error { + if govalidator.IsNull(payload.StackFileContent) { + return portainer.Error("Invalid stack file content") + } + return nil +} + +type updateSwarmStackPayload struct { + StackFileContent string + Env []portainer.Pair + Prune bool +} + +func (payload *updateSwarmStackPayload) Validate(r *http.Request) error { + if govalidator.IsNull(payload.StackFileContent) { + return portainer.Error("Invalid stack file content") + } + return nil +} + +// PUT request on /api/stacks/:id +func (handler *Handler) stackUpdate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + stackID, err := request.RetrieveRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid stack identifier route variable", err} + } + + stack, err := handler.StackService.Stack(portainer.StackID(stackID)) + if err == portainer.ErrStackNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find a stack with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a stack with the specified identifier inside the database", err} + } + + resourceControl, err := handler.ResourceControlService.ResourceControlByResourceID(stack.Name) + if err != nil && err != portainer.ErrResourceControlNotFound { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a resource control associated to the stack", err} + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} + } + + if resourceControl != nil { + if !securityContext.IsAdmin && !proxy.CanAccessStack(stack, resourceControl, securityContext.UserID, securityContext.UserMemberships) { + return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", portainer.ErrResourceAccessDenied} + } + } + + endpoint, err := handler.EndpointService.Endpoint(stack.EndpointID) + if err == portainer.ErrEndpointNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find the endpoint associated to the stack inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find the endpoint associated to the stack inside the database", err} + } + + updateError := handler.updateAndDeployStack(r, stack, endpoint) + if updateError != nil { + return updateError + } + + err = handler.StackService.UpdateStack(stack.ID, stack) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the stack changes inside the database", err} + } + + return response.JSON(w, stack) +} + +func (handler *Handler) updateAndDeployStack(r *http.Request, stack *portainer.Stack, endpoint *portainer.Endpoint) *httperror.HandlerError { + if stack.Type == portainer.DockerSwarmStack { + return handler.updateSwarmStack(r, stack, endpoint) + } + return handler.updateComposeStack(r, stack, endpoint) +} + +func (handler *Handler) updateComposeStack(r *http.Request, stack *portainer.Stack, endpoint *portainer.Endpoint) *httperror.HandlerError { + var payload updateComposeStackPayload + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + _, err = handler.FileService.StoreStackFileFromBytes(string(stack.ID), stack.EntryPoint, []byte(payload.StackFileContent)) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist updated Compose file on disk", err} + } + + config, configErr := handler.createComposeDeployConfig(r, stack, endpoint) + if configErr != nil { + return configErr + } + + err = handler.deployComposeStack(config) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, err.Error(), err} + } + + return nil +} + +func (handler *Handler) updateSwarmStack(r *http.Request, stack *portainer.Stack, endpoint *portainer.Endpoint) *httperror.HandlerError { + var payload updateSwarmStackPayload + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + stack.Env = payload.Env + + _, err = handler.FileService.StoreStackFileFromBytes(string(stack.ID), stack.EntryPoint, []byte(payload.StackFileContent)) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist updated Compose file on disk", err} + } + + config, configErr := handler.createSwarmDeployConfig(r, stack, endpoint, payload.Prune) + if configErr != nil { + return configErr + } + + err = handler.deploySwarmStack(config) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, err.Error(), err} + } + + return nil +} diff --git a/api/http/handler/status.go b/api/http/handler/status.go deleted file mode 100644 index 6bae3c8a7..000000000 --- a/api/http/handler/status.go +++ /dev/null @@ -1,38 +0,0 @@ -package handler - -import ( - "github.com/portainer/portainer" - "github.com/portainer/portainer/http/security" - - "log" - "net/http" - "os" - - "github.com/gorilla/mux" -) - -// StatusHandler represents an HTTP API handler for managing Status. -type StatusHandler struct { - *mux.Router - Logger *log.Logger - Status *portainer.Status -} - -// NewStatusHandler returns a new instance of StatusHandler. -func NewStatusHandler(bouncer *security.RequestBouncer, status *portainer.Status) *StatusHandler { - h := &StatusHandler{ - Router: mux.NewRouter(), - Logger: log.New(os.Stderr, "", log.LstdFlags), - Status: status, - } - h.Handle("/status", - bouncer.PublicAccess(http.HandlerFunc(h.handleGetStatus))).Methods(http.MethodGet) - - return h -} - -// handleGetStatus handles GET requests on /status -func (handler *StatusHandler) handleGetStatus(w http.ResponseWriter, r *http.Request) { - encodeJSON(w, handler.Status, handler.Logger) - return -} diff --git a/api/http/handler/status/handler.go b/api/http/handler/status/handler.go new file mode 100644 index 000000000..692c64130 --- /dev/null +++ b/api/http/handler/status/handler.go @@ -0,0 +1,28 @@ +package status + +import ( + "net/http" + + "github.com/gorilla/mux" + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/security" +) + +// Handler is the HTTP handler used to handle status operations. +type Handler struct { + *mux.Router + Status *portainer.Status +} + +// NewHandler creates a handler to manage status operations. +func NewHandler(bouncer *security.RequestBouncer, status *portainer.Status) *Handler { + h := &Handler{ + Router: mux.NewRouter(), + Status: status, + } + h.Handle("/status", + bouncer.PublicAccess(httperror.LoggerHandler(h.statusInspect))).Methods(http.MethodGet) + + return h +} diff --git a/api/http/handler/status/status_inspect.go b/api/http/handler/status/status_inspect.go new file mode 100644 index 000000000..93d379179 --- /dev/null +++ b/api/http/handler/status/status_inspect.go @@ -0,0 +1,13 @@ +package status + +import ( + "net/http" + + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/response" +) + +// GET request on /api/status +func (handler *Handler) statusInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + return response.JSON(w, handler.Status) +} diff --git a/api/http/handler/team.go b/api/http/handler/team.go deleted file mode 100644 index 1bf90e689..000000000 --- a/api/http/handler/team.go +++ /dev/null @@ -1,262 +0,0 @@ -package handler - -import ( - "strconv" - - "github.com/portainer/portainer" - httperror "github.com/portainer/portainer/http/error" - "github.com/portainer/portainer/http/security" - - "encoding/json" - "log" - "net/http" - "os" - - "github.com/asaskevich/govalidator" - "github.com/gorilla/mux" -) - -// TeamHandler represents an HTTP API handler for managing teams. -type TeamHandler struct { - *mux.Router - Logger *log.Logger - TeamService portainer.TeamService - TeamMembershipService portainer.TeamMembershipService - ResourceControlService portainer.ResourceControlService -} - -// NewTeamHandler returns a new instance of TeamHandler. -func NewTeamHandler(bouncer *security.RequestBouncer) *TeamHandler { - h := &TeamHandler{ - Router: mux.NewRouter(), - Logger: log.New(os.Stderr, "", log.LstdFlags), - } - h.Handle("/teams", - bouncer.AdministratorAccess(http.HandlerFunc(h.handlePostTeams))).Methods(http.MethodPost) - h.Handle("/teams", - bouncer.RestrictedAccess(http.HandlerFunc(h.handleGetTeams))).Methods(http.MethodGet) - h.Handle("/teams/{id}", - bouncer.RestrictedAccess(http.HandlerFunc(h.handleGetTeam))).Methods(http.MethodGet) - h.Handle("/teams/{id}", - bouncer.AdministratorAccess(http.HandlerFunc(h.handlePutTeam))).Methods(http.MethodPut) - h.Handle("/teams/{id}", - bouncer.AdministratorAccess(http.HandlerFunc(h.handleDeleteTeam))).Methods(http.MethodDelete) - h.Handle("/teams/{id}/memberships", - bouncer.RestrictedAccess(http.HandlerFunc(h.handleGetMemberships))).Methods(http.MethodGet) - - return h -} - -type ( - postTeamsRequest struct { - Name string `valid:"required"` - } - - postTeamsResponse struct { - ID int `json:"Id"` - } - - putTeamRequest struct { - Name string `valid:"-"` - } -) - -// handlePostTeams handles POST requests on /teams -func (handler *TeamHandler) handlePostTeams(w http.ResponseWriter, r *http.Request) { - var req postTeamsRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger) - return - } - - _, err := govalidator.ValidateStruct(req) - if err != nil { - httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) - return - } - - team, err := handler.TeamService.TeamByName(req.Name) - if err != nil && err != portainer.ErrTeamNotFound { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - if team != nil { - httperror.WriteErrorResponse(w, portainer.ErrTeamAlreadyExists, http.StatusConflict, handler.Logger) - return - } - - team = &portainer.Team{ - Name: req.Name, - } - - err = handler.TeamService.CreateTeam(team) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - encodeJSON(w, &postTeamsResponse{ID: int(team.ID)}, handler.Logger) -} - -// handleGetTeams handles GET requests on /teams -func (handler *TeamHandler) handleGetTeams(w http.ResponseWriter, r *http.Request) { - securityContext, err := security.RetrieveRestrictedRequestContext(r) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - teams, err := handler.TeamService.Teams() - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - filteredTeams := security.FilterUserTeams(teams, securityContext) - - encodeJSON(w, filteredTeams, handler.Logger) -} - -// handleGetTeam handles GET requests on /teams/:id -func (handler *TeamHandler) handleGetTeam(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - id := vars["id"] - - tid, err := strconv.Atoi(id) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) - return - } - teamID := portainer.TeamID(tid) - - securityContext, err := security.RetrieveRestrictedRequestContext(r) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - if !security.AuthorizedTeamManagement(teamID, securityContext) { - httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, handler.Logger) - return - } - - team, err := handler.TeamService.Team(teamID) - if err == portainer.ErrTeamNotFound { - httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) - return - } else if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - encodeJSON(w, &team, handler.Logger) -} - -// handlePutTeam handles PUT requests on /teams/:id -func (handler *TeamHandler) handlePutTeam(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - id := vars["id"] - - teamID, err := strconv.Atoi(id) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) - return - } - - var req putTeamRequest - if err = json.NewDecoder(r.Body).Decode(&req); err != nil { - httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger) - return - } - - _, err = govalidator.ValidateStruct(req) - if err != nil { - httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) - return - } - - team, err := handler.TeamService.Team(portainer.TeamID(teamID)) - if err == portainer.ErrTeamNotFound { - httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) - return - } else if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - if req.Name != "" { - team.Name = req.Name - } - - err = handler.TeamService.UpdateTeam(team.ID, team) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } -} - -// handleDeleteTeam handles DELETE requests on /teams/:id -func (handler *TeamHandler) handleDeleteTeam(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - id := vars["id"] - - teamID, err := strconv.Atoi(id) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) - return - } - - _, err = handler.TeamService.Team(portainer.TeamID(teamID)) - - if err == portainer.ErrTeamNotFound { - httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) - return - } else if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - err = handler.TeamService.DeleteTeam(portainer.TeamID(teamID)) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - err = handler.TeamMembershipService.DeleteTeamMembershipByTeamID(portainer.TeamID(teamID)) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } -} - -// handleGetMemberships handles GET requests on /teams/:id/memberships -func (handler *TeamHandler) handleGetMemberships(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - id := vars["id"] - - tid, err := strconv.Atoi(id) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) - return - } - teamID := portainer.TeamID(tid) - - securityContext, err := security.RetrieveRestrictedRequestContext(r) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - if !security.AuthorizedTeamManagement(teamID, securityContext) { - httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, handler.Logger) - return - } - - memberships, err := handler.TeamMembershipService.TeamMembershipsByTeamID(teamID) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - encodeJSON(w, memberships, handler.Logger) -} diff --git a/api/http/handler/team_membership.go b/api/http/handler/team_membership.go deleted file mode 100644 index c96f5c8ca..000000000 --- a/api/http/handler/team_membership.go +++ /dev/null @@ -1,242 +0,0 @@ -package handler - -import ( - "strconv" - - "github.com/portainer/portainer" - httperror "github.com/portainer/portainer/http/error" - "github.com/portainer/portainer/http/security" - - "encoding/json" - "log" - "net/http" - "os" - - "github.com/asaskevich/govalidator" - "github.com/gorilla/mux" -) - -// TeamMembershipHandler represents an HTTP API handler for managing teams. -type TeamMembershipHandler struct { - *mux.Router - Logger *log.Logger - TeamMembershipService portainer.TeamMembershipService - ResourceControlService portainer.ResourceControlService -} - -// NewTeamMembershipHandler returns a new instance of TeamMembershipHandler. -func NewTeamMembershipHandler(bouncer *security.RequestBouncer) *TeamMembershipHandler { - h := &TeamMembershipHandler{ - Router: mux.NewRouter(), - Logger: log.New(os.Stderr, "", log.LstdFlags), - } - h.Handle("/team_memberships", - bouncer.RestrictedAccess(http.HandlerFunc(h.handlePostTeamMemberships))).Methods(http.MethodPost) - h.Handle("/team_memberships", - bouncer.RestrictedAccess(http.HandlerFunc(h.handleGetTeamsMemberships))).Methods(http.MethodGet) - h.Handle("/team_memberships/{id}", - bouncer.RestrictedAccess(http.HandlerFunc(h.handlePutTeamMembership))).Methods(http.MethodPut) - h.Handle("/team_memberships/{id}", - bouncer.RestrictedAccess(http.HandlerFunc(h.handleDeleteTeamMembership))).Methods(http.MethodDelete) - - return h -} - -type ( - postTeamMembershipsRequest struct { - UserID int `valid:"required"` - TeamID int `valid:"required"` - Role int `valid:"required"` - } - - postTeamMembershipsResponse struct { - ID int `json:"Id"` - } - - putTeamMembershipRequest struct { - UserID int `valid:"required"` - TeamID int `valid:"required"` - Role int `valid:"required"` - } -) - -// handlePostTeamMemberships handles POST requests on /team_memberships -func (handler *TeamMembershipHandler) handlePostTeamMemberships(w http.ResponseWriter, r *http.Request) { - securityContext, err := security.RetrieveRestrictedRequestContext(r) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - var req postTeamMembershipsRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger) - return - } - - _, err = govalidator.ValidateStruct(req) - if err != nil { - httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) - return - } - - userID := portainer.UserID(req.UserID) - teamID := portainer.TeamID(req.TeamID) - role := portainer.MembershipRole(req.Role) - - if !security.AuthorizedTeamManagement(teamID, securityContext) { - httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, handler.Logger) - return - } - - memberships, err := handler.TeamMembershipService.TeamMembershipsByUserID(userID) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - if len(memberships) > 0 { - for _, membership := range memberships { - if membership.UserID == userID && membership.TeamID == teamID { - httperror.WriteErrorResponse(w, portainer.ErrTeamMembershipAlreadyExists, http.StatusConflict, handler.Logger) - return - } - } - } - - membership := &portainer.TeamMembership{ - UserID: userID, - TeamID: teamID, - Role: role, - } - - err = handler.TeamMembershipService.CreateTeamMembership(membership) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - encodeJSON(w, &postTeamMembershipsResponse{ID: int(membership.ID)}, handler.Logger) -} - -// handleGetTeamsMemberships handles GET requests on /team_memberships -func (handler *TeamMembershipHandler) handleGetTeamsMemberships(w http.ResponseWriter, r *http.Request) { - securityContext, err := security.RetrieveRestrictedRequestContext(r) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - if !securityContext.IsAdmin && !securityContext.IsTeamLeader { - httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, handler.Logger) - return - } - - memberships, err := handler.TeamMembershipService.TeamMemberships() - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - encodeJSON(w, memberships, handler.Logger) -} - -// handlePutTeamMembership handles PUT requests on /team_memberships/:id -func (handler *TeamMembershipHandler) handlePutTeamMembership(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - id := vars["id"] - - membershipID, err := strconv.Atoi(id) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) - return - } - - var req putTeamMembershipRequest - if err = json.NewDecoder(r.Body).Decode(&req); err != nil { - httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger) - return - } - - _, err = govalidator.ValidateStruct(req) - if err != nil { - httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) - return - } - - userID := portainer.UserID(req.UserID) - teamID := portainer.TeamID(req.TeamID) - role := portainer.MembershipRole(req.Role) - - securityContext, err := security.RetrieveRestrictedRequestContext(r) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - if !security.AuthorizedTeamManagement(teamID, securityContext) { - httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, handler.Logger) - return - } - - membership, err := handler.TeamMembershipService.TeamMembership(portainer.TeamMembershipID(membershipID)) - if err == portainer.ErrTeamMembershipNotFound { - httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) - return - } else if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - if securityContext.IsTeamLeader && membership.Role != role { - httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, handler.Logger) - return - } - - membership.UserID = userID - membership.TeamID = teamID - membership.Role = role - - err = handler.TeamMembershipService.UpdateTeamMembership(membership.ID, membership) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } -} - -// handleDeleteTeamMembership handles DELETE requests on /team_memberships/:id -func (handler *TeamMembershipHandler) handleDeleteTeamMembership(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - id := vars["id"] - - membershipID, err := strconv.Atoi(id) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) - return - } - - membership, err := handler.TeamMembershipService.TeamMembership(portainer.TeamMembershipID(membershipID)) - if err == portainer.ErrTeamMembershipNotFound { - httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) - return - } else if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - securityContext, err := security.RetrieveRestrictedRequestContext(r) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - if !security.AuthorizedTeamManagement(membership.TeamID, securityContext) { - httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, handler.Logger) - return - } - - err = handler.TeamMembershipService.DeleteTeamMembership(portainer.TeamMembershipID(membershipID)) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } -} diff --git a/api/http/handler/teammemberships/handler.go b/api/http/handler/teammemberships/handler.go new file mode 100644 index 000000000..c50773a85 --- /dev/null +++ b/api/http/handler/teammemberships/handler.go @@ -0,0 +1,35 @@ +package teammemberships + +import ( + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/security" + + "net/http" + + "github.com/gorilla/mux" +) + +// Handler is the HTTP handler used to handle team membership operations. +type Handler struct { + *mux.Router + TeamMembershipService portainer.TeamMembershipService + ResourceControlService portainer.ResourceControlService +} + +// NewHandler creates a handler to manage team membership operations. +func NewHandler(bouncer *security.RequestBouncer) *Handler { + h := &Handler{ + Router: mux.NewRouter(), + } + h.Handle("/team_memberships", + bouncer.RestrictedAccess(httperror.LoggerHandler(h.teamMembershipCreate))).Methods(http.MethodPost) + h.Handle("/team_memberships", + bouncer.RestrictedAccess(httperror.LoggerHandler(h.teamMembershipList))).Methods(http.MethodGet) + h.Handle("/team_memberships/{id}", + bouncer.RestrictedAccess(httperror.LoggerHandler(h.teamMembershipUpdate))).Methods(http.MethodPut) + h.Handle("/team_memberships/{id}", + bouncer.RestrictedAccess(httperror.LoggerHandler(h.teamMembershipDelete))).Methods(http.MethodDelete) + + return h +} diff --git a/api/http/handler/teammemberships/teammembership_create.go b/api/http/handler/teammemberships/teammembership_create.go new file mode 100644 index 000000000..49783d767 --- /dev/null +++ b/api/http/handler/teammemberships/teammembership_create.go @@ -0,0 +1,74 @@ +package teammemberships + +import ( + "net/http" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" + "github.com/portainer/portainer/http/security" +) + +type teamMembershipCreatePayload struct { + UserID int + TeamID int + Role int +} + +func (payload *teamMembershipCreatePayload) Validate(r *http.Request) error { + if payload.UserID == 0 { + return portainer.Error("Invalid UserID") + } + if payload.TeamID == 0 { + return portainer.Error("Invalid TeamID") + } + if payload.Role != 1 && payload.Role != 2 { + return portainer.Error("Invalid role value. Value must be one of: 1 (leader) or 2 (member)") + } + return nil +} + +// POST request on /api/team_memberships +func (handler *Handler) teamMembershipCreate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + var payload teamMembershipCreatePayload + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} + } + + if !security.AuthorizedTeamManagement(portainer.TeamID(payload.TeamID), securityContext) { + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to manage team memberships", portainer.ErrResourceAccessDenied} + } + + memberships, err := handler.TeamMembershipService.TeamMembershipsByUserID(portainer.UserID(payload.UserID)) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve team memberships from the database", err} + } + + if len(memberships) > 0 { + for _, membership := range memberships { + if membership.UserID == portainer.UserID(payload.UserID) && membership.TeamID == portainer.TeamID(payload.TeamID) { + return &httperror.HandlerError{http.StatusConflict, "Team membership already registered", portainer.ErrTeamMembershipAlreadyExists} + } + } + } + + membership := &portainer.TeamMembership{ + UserID: portainer.UserID(payload.UserID), + TeamID: portainer.TeamID(payload.TeamID), + Role: portainer.MembershipRole(payload.Role), + } + + err = handler.TeamMembershipService.CreateTeamMembership(membership) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist team memberships inside the database", err} + } + + return response.JSON(w, membership) +} diff --git a/api/http/handler/teammemberships/teammembership_delete.go b/api/http/handler/teammemberships/teammembership_delete.go new file mode 100644 index 000000000..577f3be7d --- /dev/null +++ b/api/http/handler/teammemberships/teammembership_delete.go @@ -0,0 +1,42 @@ +package teammemberships + +import ( + "net/http" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" + "github.com/portainer/portainer/http/security" +) + +// DELETE request on /api/team_memberships/:id +func (handler *Handler) teamMembershipDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + membershipID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid membership identifier route variable", err} + } + + membership, err := handler.TeamMembershipService.TeamMembership(portainer.TeamMembershipID(membershipID)) + if err == portainer.ErrTeamMembershipNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find a team membership with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a team membership with the specified identifier inside the database", err} + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} + } + + if !security.AuthorizedTeamManagement(membership.TeamID, securityContext) { + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to delete the membership", portainer.ErrResourceAccessDenied} + } + + err = handler.TeamMembershipService.DeleteTeamMembership(portainer.TeamMembershipID(membershipID)) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove the team membership from the database", err} + } + + return response.Empty(w) +} diff --git a/api/http/handler/teammemberships/teammembership_list.go b/api/http/handler/teammemberships/teammembership_list.go new file mode 100644 index 000000000..0f9267a10 --- /dev/null +++ b/api/http/handler/teammemberships/teammembership_list.go @@ -0,0 +1,29 @@ +package teammemberships + +import ( + "net/http" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/response" + "github.com/portainer/portainer/http/security" +) + +// GET request on /api/team_memberships +func (handler *Handler) teamMembershipList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} + } + + if !securityContext.IsAdmin && !securityContext.IsTeamLeader { + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to list team memberships", portainer.ErrResourceAccessDenied} + } + + memberships, err := handler.TeamMembershipService.TeamMemberships() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve team memberships from the database", err} + } + + return response.JSON(w, memberships) +} diff --git a/api/http/handler/teammemberships/teammembership_update.go b/api/http/handler/teammemberships/teammembership_update.go new file mode 100644 index 000000000..953ca5618 --- /dev/null +++ b/api/http/handler/teammemberships/teammembership_update.go @@ -0,0 +1,75 @@ +package teammemberships + +import ( + "net/http" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" + "github.com/portainer/portainer/http/security" +) + +type teamMembershipUpdatePayload struct { + UserID int + TeamID int + Role int +} + +func (payload *teamMembershipUpdatePayload) Validate(r *http.Request) error { + if payload.UserID == 0 { + return portainer.Error("Invalid UserID") + } + if payload.TeamID == 0 { + return portainer.Error("Invalid TeamID") + } + if payload.Role != 1 && payload.Role != 2 { + return portainer.Error("Invalid role value. Value must be one of: 1 (leader) or 2 (member)") + } + return nil +} + +// PUT request on /api/team_memberships/:id +func (handler *Handler) teamMembershipUpdate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + membershipID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid membership identifier route variable", err} + } + + var payload teamMembershipUpdatePayload + err = request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} + } + + if !security.AuthorizedTeamManagement(portainer.TeamID(payload.TeamID), securityContext) { + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to update the membership", portainer.ErrResourceAccessDenied} + } + + membership, err := handler.TeamMembershipService.TeamMembership(portainer.TeamMembershipID(membershipID)) + if err == portainer.ErrTeamMembershipNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find a team membership with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a team membership with the specified identifier inside the database", err} + } + + if securityContext.IsTeamLeader && membership.Role != portainer.MembershipRole(payload.Role) { + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to update the role of membership", portainer.ErrResourceAccessDenied} + } + + membership.UserID = portainer.UserID(payload.UserID) + membership.TeamID = portainer.TeamID(payload.TeamID) + membership.Role = portainer.MembershipRole(payload.Role) + + err = handler.TeamMembershipService.UpdateTeamMembership(membership.ID, membership) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist membership changes inside the database", err} + } + + return response.JSON(w, membership) +} diff --git a/api/http/handler/teams/handler.go b/api/http/handler/teams/handler.go new file mode 100644 index 000000000..2b8cd7c3b --- /dev/null +++ b/api/http/handler/teams/handler.go @@ -0,0 +1,39 @@ +package teams + +import ( + "net/http" + + "github.com/gorilla/mux" + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/security" +) + +// Handler is the HTTP handler used to handle team operations. +type Handler struct { + *mux.Router + TeamService portainer.TeamService + TeamMembershipService portainer.TeamMembershipService + ResourceControlService portainer.ResourceControlService +} + +// NewHandler creates a handler to manage team operations. +func NewHandler(bouncer *security.RequestBouncer) *Handler { + h := &Handler{ + Router: mux.NewRouter(), + } + h.Handle("/teams", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.teamCreate))).Methods(http.MethodPost) + h.Handle("/teams", + bouncer.RestrictedAccess(httperror.LoggerHandler(h.teamList))).Methods(http.MethodGet) + h.Handle("/teams/{id}", + bouncer.RestrictedAccess(httperror.LoggerHandler(h.teamInspect))).Methods(http.MethodGet) + h.Handle("/teams/{id}", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.teamUpdate))).Methods(http.MethodPut) + h.Handle("/teams/{id}", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.teamDelete))).Methods(http.MethodDelete) + h.Handle("/teams/{id}/memberships", + bouncer.RestrictedAccess(httperror.LoggerHandler(h.teamMemberships))).Methods(http.MethodGet) + + return h +} diff --git a/api/http/handler/teams/team_create.go b/api/http/handler/teams/team_create.go new file mode 100644 index 000000000..49dc6f093 --- /dev/null +++ b/api/http/handler/teams/team_create.go @@ -0,0 +1,49 @@ +package teams + +import ( + "net/http" + + "github.com/asaskevich/govalidator" + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" +) + +type teamCreatePayload struct { + Name string +} + +func (payload *teamCreatePayload) Validate(r *http.Request) error { + if govalidator.IsNull(payload.Name) { + return portainer.Error("Invalid team name") + } + return nil +} + +func (handler *Handler) teamCreate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + var payload teamCreatePayload + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + team, err := handler.TeamService.TeamByName(payload.Name) + if err != nil && err != portainer.ErrTeamNotFound { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve teams from the database", err} + } + if team != nil { + return &httperror.HandlerError{http.StatusConflict, "A team with the same name already exists", portainer.ErrTeamAlreadyExists} + } + + team = &portainer.Team{ + Name: payload.Name, + } + + err = handler.TeamService.CreateTeam(team) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the team inside the database", err} + } + + return response.JSON(w, team) +} diff --git a/api/http/handler/teams/team_delete.go b/api/http/handler/teams/team_delete.go new file mode 100644 index 000000000..ab805248b --- /dev/null +++ b/api/http/handler/teams/team_delete.go @@ -0,0 +1,37 @@ +package teams + +import ( + "net/http" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" +) + +// DELETE request on /api/teams/:id +func (handler *Handler) teamDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + teamID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid team identifier route variable", err} + } + + _, err = handler.TeamService.Team(portainer.TeamID(teamID)) + if err == portainer.ErrTeamNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find a team with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a team with the specified identifier inside the database", err} + } + + err = handler.TeamService.DeleteTeam(portainer.TeamID(teamID)) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to delete the team from the database", err} + } + + err = handler.TeamMembershipService.DeleteTeamMembershipByTeamID(portainer.TeamID(teamID)) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to delete associated team memberships from the database", err} + } + + return response.Empty(w) +} diff --git a/api/http/handler/teams/team_inspect.go b/api/http/handler/teams/team_inspect.go new file mode 100644 index 000000000..b6c6ab1a6 --- /dev/null +++ b/api/http/handler/teams/team_inspect.go @@ -0,0 +1,37 @@ +package teams + +import ( + "net/http" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" + "github.com/portainer/portainer/http/security" +) + +// GET request on /api/teams/:id +func (handler *Handler) teamInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + teamID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid team identifier route variable", err} + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} + } + + if !security.AuthorizedTeamManagement(portainer.TeamID(teamID), securityContext) { + return &httperror.HandlerError{http.StatusForbidden, "Access denied to team", portainer.ErrResourceAccessDenied} + } + + team, err := handler.TeamService.Team(portainer.TeamID(teamID)) + if err == portainer.ErrTeamNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find a team with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a team with the specified identifier inside the database", err} + } + + return response.JSON(w, team) +} diff --git a/api/http/handler/teams/team_list.go b/api/http/handler/teams/team_list.go new file mode 100644 index 000000000..7c4268e13 --- /dev/null +++ b/api/http/handler/teams/team_list.go @@ -0,0 +1,26 @@ +package teams + +import ( + "net/http" + + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/response" + "github.com/portainer/portainer/http/security" +) + +// GET request on /api/teams +func (handler *Handler) teamList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + teams, err := handler.TeamService.Teams() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve teams from the database", err} + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} + } + + filteredTeams := security.FilterUserTeams(teams, securityContext) + + return response.JSON(w, filteredTeams) +} diff --git a/api/http/handler/teams/team_memberships.go b/api/http/handler/teams/team_memberships.go new file mode 100644 index 000000000..d09abe7d5 --- /dev/null +++ b/api/http/handler/teams/team_memberships.go @@ -0,0 +1,35 @@ +package teams + +import ( + "net/http" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" + "github.com/portainer/portainer/http/security" +) + +// GET request on /api/teams/:id/memberships +func (handler *Handler) teamMemberships(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + teamID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid team identifier route variable", err} + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} + } + + if !security.AuthorizedTeamManagement(portainer.TeamID(teamID), securityContext) { + return &httperror.HandlerError{http.StatusForbidden, "Access denied to team", portainer.ErrResourceAccessDenied} + } + + memberships, err := handler.TeamMembershipService.TeamMembershipsByTeamID(portainer.TeamID(teamID)) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve associated team memberships from the database", err} + } + + return response.JSON(w, memberships) +} diff --git a/api/http/handler/teams/team_update.go b/api/http/handler/teams/team_update.go new file mode 100644 index 000000000..ea80f0dd5 --- /dev/null +++ b/api/http/handler/teams/team_update.go @@ -0,0 +1,50 @@ +package teams + +import ( + "net/http" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" +) + +type teamUpdatePayload struct { + Name string +} + +func (payload *teamUpdatePayload) Validate(r *http.Request) error { + return nil +} + +// PUT request on /api/teams/:id +func (handler *Handler) teamUpdate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + teamID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid team identifier route variable", err} + } + + var payload teamUpdatePayload + err = request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + team, err := handler.TeamService.Team(portainer.TeamID(teamID)) + if err == portainer.ErrTeamNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find a team with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a team with the specified identifier inside the database", err} + } + + if payload.Name != "" { + team.Name = payload.Name + } + + err = handler.TeamService.UpdateTeam(team.ID, team) + if err != nil { + return &httperror.HandlerError{http.StatusNotFound, "Unable to persist team changes inside the database", err} + } + + return response.JSON(w, team) +} diff --git a/api/http/handler/templates.go b/api/http/handler/templates.go deleted file mode 100644 index 83527870b..000000000 --- a/api/http/handler/templates.go +++ /dev/null @@ -1,74 +0,0 @@ -package handler - -import ( - "io/ioutil" - "log" - "net/http" - "os" - - "github.com/gorilla/mux" - "github.com/portainer/portainer" - httperror "github.com/portainer/portainer/http/error" - "github.com/portainer/portainer/http/security" -) - -// TemplatesHandler represents an HTTP API handler for managing templates. -type TemplatesHandler struct { - *mux.Router - Logger *log.Logger - SettingsService portainer.SettingsService -} - -const ( - containerTemplatesURLLinuxServerIo = "https://tools.linuxserver.io/portainer.json" -) - -// NewTemplatesHandler returns a new instance of TemplatesHandler. -func NewTemplatesHandler(bouncer *security.RequestBouncer) *TemplatesHandler { - h := &TemplatesHandler{ - Router: mux.NewRouter(), - Logger: log.New(os.Stderr, "", log.LstdFlags), - } - h.Handle("/templates", - bouncer.AuthenticatedAccess(http.HandlerFunc(h.handleGetTemplates))).Methods(http.MethodGet) - return h -} - -// handleGetTemplates handles GET requests on /templates?key= -func (handler *TemplatesHandler) handleGetTemplates(w http.ResponseWriter, r *http.Request) { - key := r.FormValue("key") - if key == "" { - httperror.WriteErrorResponse(w, ErrInvalidQueryFormat, http.StatusBadRequest, handler.Logger) - return - } - - var templatesURL string - switch key { - case "containers": - settings, err := handler.SettingsService.Settings() - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - templatesURL = settings.TemplatesURL - case "linuxserver.io": - templatesURL = containerTemplatesURLLinuxServerIo - default: - httperror.WriteErrorResponse(w, ErrInvalidQueryFormat, http.StatusBadRequest, handler.Logger) - return - } - - resp, err := http.Get(templatesURL) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - defer resp.Body.Close() - body, err := ioutil.ReadAll(resp.Body) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - w.Header().Set("Content-Type", "application/json") - w.Write(body) -} diff --git a/api/http/handler/templates/handler.go b/api/http/handler/templates/handler.go new file mode 100644 index 000000000..f4d0c6fcf --- /dev/null +++ b/api/http/handler/templates/handler.go @@ -0,0 +1,30 @@ +package templates + +import ( + "net/http" + + "github.com/gorilla/mux" + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/security" +) + +const ( + containerTemplatesURLLinuxServerIo = "https://tools.linuxserver.io/portainer.json" +) + +// Handler represents an HTTP API handler for managing templates. +type Handler struct { + *mux.Router + SettingsService portainer.SettingsService +} + +// NewHandler returns a new instance of Handler. +func NewHandler(bouncer *security.RequestBouncer) *Handler { + h := &Handler{ + Router: mux.NewRouter(), + } + h.Handle("/templates", + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.templateList))).Methods(http.MethodGet) + return h +} diff --git a/api/http/handler/templates/template_list.go b/api/http/handler/templates/template_list.go new file mode 100644 index 000000000..188951201 --- /dev/null +++ b/api/http/handler/templates/template_list.go @@ -0,0 +1,50 @@ +package templates + +import ( + "io/ioutil" + "net/http" + + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" +) + +// GET request on /api/templates?key= +func (handler *Handler) templateList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + key, err := request.RetrieveQueryParameter(r, "key", false) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: key", err} + } + + templatesURL, templateErr := handler.retrieveTemplateURLFromKey(key) + if templateErr != nil { + return templateErr + } + + resp, err := http.Get(templatesURL) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve templates via the network", err} + } + defer resp.Body.Close() + + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to read template response", err} + } + + return response.Bytes(w, body, "application/json") +} + +func (handler *Handler) retrieveTemplateURLFromKey(key string) (string, *httperror.HandlerError) { + switch key { + case "containers": + settings, err := handler.SettingsService.Settings() + if err != nil { + return "", &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve settings from the database", err} + } + return settings.TemplatesURL, nil + case "linuxserver.io": + return containerTemplatesURLLinuxServerIo, nil + } + return "", &httperror.HandlerError{http.StatusBadRequest, "Invalid value for query parameter: key. Value must be one of: containers or linuxserver.io", request.ErrInvalidQueryParameter} +} diff --git a/api/http/handler/upload.go b/api/http/handler/upload.go deleted file mode 100644 index 0343d1a2f..000000000 --- a/api/http/handler/upload.go +++ /dev/null @@ -1,69 +0,0 @@ -package handler - -import ( - "github.com/portainer/portainer" - httperror "github.com/portainer/portainer/http/error" - "github.com/portainer/portainer/http/security" - - "log" - "net/http" - "os" - - "github.com/gorilla/mux" -) - -// UploadHandler represents an HTTP API handler for managing file uploads. -type UploadHandler struct { - *mux.Router - Logger *log.Logger - FileService portainer.FileService -} - -// NewUploadHandler returns a new instance of UploadHandler. -func NewUploadHandler(bouncer *security.RequestBouncer) *UploadHandler { - h := &UploadHandler{ - Router: mux.NewRouter(), - Logger: log.New(os.Stderr, "", log.LstdFlags), - } - h.Handle("/upload/tls/{certificate:(?:ca|cert|key)}", - bouncer.AdministratorAccess(http.HandlerFunc(h.handlePostUploadTLS))).Methods(http.MethodPost) - return h -} - -// handlePostUploadTLS handles POST requests on /upload/tls/{certificate:(?:ca|cert|key)}?folder= -func (handler *UploadHandler) handlePostUploadTLS(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - certificate := vars["certificate"] - - folder := r.FormValue("folder") - if folder == "" { - httperror.WriteErrorResponse(w, ErrInvalidQueryFormat, http.StatusBadRequest, handler.Logger) - return - } - - file, _, err := r.FormFile("file") - defer file.Close() - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - var fileType portainer.TLSFileType - switch certificate { - case "ca": - fileType = portainer.TLSFileCA - case "cert": - fileType = portainer.TLSFileCert - case "key": - fileType = portainer.TLSFileKey - default: - httperror.WriteErrorResponse(w, portainer.ErrUndefinedTLSFileType, http.StatusInternalServerError, handler.Logger) - return - } - - err = handler.FileService.StoreTLSFile(folder, fileType, file) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } -} diff --git a/api/http/handler/upload/handler.go b/api/http/handler/upload/handler.go new file mode 100644 index 000000000..6ce36df77 --- /dev/null +++ b/api/http/handler/upload/handler.go @@ -0,0 +1,27 @@ +package upload + +import ( + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/security" + + "net/http" + + "github.com/gorilla/mux" +) + +// Handler is the HTTP handler used to handle upload operations. +type Handler struct { + *mux.Router + FileService portainer.FileService +} + +// NewHandler creates a handler to manage upload operations. +func NewHandler(bouncer *security.RequestBouncer) *Handler { + h := &Handler{ + Router: mux.NewRouter(), + } + h.Handle("/upload/tls/{certificate:(?:ca|cert|key)}", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.uploadTLS))).Methods(http.MethodPost) + return h +} diff --git a/api/http/handler/upload/upload_tls.go b/api/http/handler/upload/upload_tls.go new file mode 100644 index 000000000..aebab6813 --- /dev/null +++ b/api/http/handler/upload/upload_tls.go @@ -0,0 +1,47 @@ +package upload + +import ( + "net/http" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" +) + +// POST request on /api/upload/tls/{certificate:(?:ca|cert|key)}?folder= +func (handler *Handler) uploadTLS(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + certificate, err := request.RetrieveRouteVariableValue(r, "certificate") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid certificate route variable", err} + } + + folder, err := request.RetrieveMultiPartFormValue(r, "folder", false) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: folder", err} + } + + file, err := request.RetrieveMultiPartFormFile(r, "file") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid certificate file. Ensure that the certificate file is uploaded correctly", err} + } + + var fileType portainer.TLSFileType + switch certificate { + case "ca": + fileType = portainer.TLSFileCA + case "cert": + fileType = portainer.TLSFileCert + case "key": + fileType = portainer.TLSFileKey + default: + return &httperror.HandlerError{http.StatusBadRequest, "Invalid certificate route value. Value must be one of: ca, cert or key", portainer.ErrUndefinedTLSFileType} + } + + _, err = handler.FileService.StoreTLSFileFromBytes(folder, fileType, file) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist certificate file on disk", err} + } + + return response.Empty(w) +} diff --git a/api/http/handler/user.go b/api/http/handler/user.go deleted file mode 100644 index d4f34d9b4..000000000 --- a/api/http/handler/user.go +++ /dev/null @@ -1,463 +0,0 @@ -package handler - -import ( - "strconv" - "strings" - - "github.com/portainer/portainer" - httperror "github.com/portainer/portainer/http/error" - "github.com/portainer/portainer/http/security" - - "encoding/json" - "log" - "net/http" - "os" - - "github.com/asaskevich/govalidator" - "github.com/gorilla/mux" -) - -// UserHandler represents an HTTP API handler for managing users. -type UserHandler struct { - *mux.Router - Logger *log.Logger - UserService portainer.UserService - TeamService portainer.TeamService - TeamMembershipService portainer.TeamMembershipService - ResourceControlService portainer.ResourceControlService - CryptoService portainer.CryptoService - SettingsService portainer.SettingsService -} - -// NewUserHandler returns a new instance of UserHandler. -func NewUserHandler(bouncer *security.RequestBouncer) *UserHandler { - h := &UserHandler{ - Router: mux.NewRouter(), - Logger: log.New(os.Stderr, "", log.LstdFlags), - } - h.Handle("/users", - bouncer.RestrictedAccess(http.HandlerFunc(h.handlePostUsers))).Methods(http.MethodPost) - h.Handle("/users", - bouncer.RestrictedAccess(http.HandlerFunc(h.handleGetUsers))).Methods(http.MethodGet) - h.Handle("/users/{id}", - bouncer.AdministratorAccess(http.HandlerFunc(h.handleGetUser))).Methods(http.MethodGet) - h.Handle("/users/{id}", - bouncer.AuthenticatedAccess(http.HandlerFunc(h.handlePutUser))).Methods(http.MethodPut) - h.Handle("/users/{id}", - bouncer.AdministratorAccess(http.HandlerFunc(h.handleDeleteUser))).Methods(http.MethodDelete) - h.Handle("/users/{id}/memberships", - bouncer.AuthenticatedAccess(http.HandlerFunc(h.handleGetMemberships))).Methods(http.MethodGet) - h.Handle("/users/{id}/passwd", - bouncer.AuthenticatedAccess(http.HandlerFunc(h.handlePostUserPasswd))).Methods(http.MethodPost) - h.Handle("/users/admin/check", - bouncer.PublicAccess(http.HandlerFunc(h.handleGetAdminCheck))).Methods(http.MethodGet) - h.Handle("/users/admin/init", - bouncer.PublicAccess(http.HandlerFunc(h.handlePostAdminInit))).Methods(http.MethodPost) - - return h -} - -type ( - postUsersRequest struct { - Username string `valid:"required"` - Password string `valid:""` - Role int `valid:"required"` - } - - postUsersResponse struct { - ID int `json:"Id"` - } - - postUserPasswdRequest struct { - Password string `valid:"required"` - } - - postUserPasswdResponse struct { - Valid bool `json:"valid"` - } - - putUserRequest struct { - Password string `valid:"-"` - Role int `valid:"-"` - } - - postAdminInitRequest struct { - Username string `valid:"required"` - Password string `valid:"required"` - } -) - -// handlePostUsers handles POST requests on /users -func (handler *UserHandler) handlePostUsers(w http.ResponseWriter, r *http.Request) { - var req postUsersRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger) - return - } - - _, err := govalidator.ValidateStruct(req) - if err != nil { - httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) - return - } - - securityContext, err := security.RetrieveRestrictedRequestContext(r) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - if !securityContext.IsAdmin && !securityContext.IsTeamLeader { - httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, nil) - return - } - - if securityContext.IsTeamLeader && req.Role == 1 { - httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, nil) - return - } - - if strings.ContainsAny(req.Username, " ") { - httperror.WriteErrorResponse(w, portainer.ErrInvalidUsername, http.StatusBadRequest, handler.Logger) - return - } - - user, err := handler.UserService.UserByUsername(req.Username) - if err != nil && err != portainer.ErrUserNotFound { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - if user != nil { - httperror.WriteErrorResponse(w, portainer.ErrUserAlreadyExists, http.StatusConflict, handler.Logger) - return - } - - var role portainer.UserRole - if req.Role == 1 { - role = portainer.AdministratorRole - } else { - role = portainer.StandardUserRole - } - - user = &portainer.User{ - Username: req.Username, - Role: role, - } - - settings, err := handler.SettingsService.Settings() - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - if settings.AuthenticationMethod == portainer.AuthenticationInternal { - user.Password, err = handler.CryptoService.Hash(req.Password) - if err != nil { - httperror.WriteErrorResponse(w, portainer.ErrCryptoHashFailure, http.StatusBadRequest, handler.Logger) - return - } - } - - err = handler.UserService.CreateUser(user) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - encodeJSON(w, &postUsersResponse{ID: int(user.ID)}, handler.Logger) -} - -// handleGetUsers handles GET requests on /users -func (handler *UserHandler) handleGetUsers(w http.ResponseWriter, r *http.Request) { - securityContext, err := security.RetrieveRestrictedRequestContext(r) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - users, err := handler.UserService.Users() - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - filteredUsers := security.FilterUsers(users, securityContext) - - for i := range filteredUsers { - filteredUsers[i].Password = "" - } - - encodeJSON(w, filteredUsers, handler.Logger) -} - -// handlePostUserPasswd handles POST requests on /users/:id/passwd -func (handler *UserHandler) handlePostUserPasswd(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - id := vars["id"] - - userID, err := strconv.Atoi(id) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) - return - } - - var req postUserPasswdRequest - if err = json.NewDecoder(r.Body).Decode(&req); err != nil { - httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger) - return - } - - _, err = govalidator.ValidateStruct(req) - if err != nil { - httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) - return - } - - var password = req.Password - - u, err := handler.UserService.User(portainer.UserID(userID)) - if err == portainer.ErrUserNotFound { - httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) - return - } else if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - valid := true - err = handler.CryptoService.CompareHashAndData(u.Password, password) - if err != nil { - valid = false - } - - encodeJSON(w, &postUserPasswdResponse{Valid: valid}, handler.Logger) -} - -// handleGetUser handles GET requests on /users/:id -func (handler *UserHandler) handleGetUser(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - id := vars["id"] - - userID, err := strconv.Atoi(id) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) - return - } - - user, err := handler.UserService.User(portainer.UserID(userID)) - if err == portainer.ErrUserNotFound { - httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) - return - } else if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - user.Password = "" - encodeJSON(w, &user, handler.Logger) -} - -// handlePutUser handles PUT requests on /users/:id -func (handler *UserHandler) handlePutUser(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - id := vars["id"] - - userID, err := strconv.Atoi(id) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) - return - } - - tokenData, err := security.RetrieveTokenData(r) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - if tokenData.Role != portainer.AdministratorRole && tokenData.ID != portainer.UserID(userID) { - httperror.WriteErrorResponse(w, portainer.ErrUnauthorized, http.StatusForbidden, handler.Logger) - return - } - - var req putUserRequest - if err = json.NewDecoder(r.Body).Decode(&req); err != nil { - httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger) - return - } - - _, err = govalidator.ValidateStruct(req) - if err != nil { - httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) - return - } - - if req.Password == "" && req.Role == 0 { - httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) - return - } - - user, err := handler.UserService.User(portainer.UserID(userID)) - if err == portainer.ErrUserNotFound { - httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) - return - } else if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - if req.Password != "" { - user.Password, err = handler.CryptoService.Hash(req.Password) - if err != nil { - httperror.WriteErrorResponse(w, portainer.ErrCryptoHashFailure, http.StatusBadRequest, handler.Logger) - return - } - } - - if req.Role != 0 { - if tokenData.Role != portainer.AdministratorRole { - httperror.WriteErrorResponse(w, portainer.ErrUnauthorized, http.StatusForbidden, handler.Logger) - return - } - if req.Role == 1 { - user.Role = portainer.AdministratorRole - } else { - user.Role = portainer.StandardUserRole - } - } - - err = handler.UserService.UpdateUser(user.ID, user) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } -} - -// handleGetAdminCheck handles GET requests on /users/admin/check -func (handler *UserHandler) handleGetAdminCheck(w http.ResponseWriter, r *http.Request) { - users, err := handler.UserService.UsersByRole(portainer.AdministratorRole) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - if len(users) == 0 { - httperror.WriteErrorResponse(w, portainer.ErrUserNotFound, http.StatusNotFound, handler.Logger) - return - } -} - -// handlePostAdminInit handles POST requests on /users/admin/init -func (handler *UserHandler) handlePostAdminInit(w http.ResponseWriter, r *http.Request) { - var req postAdminInitRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger) - return - } - - _, err := govalidator.ValidateStruct(req) - if err != nil { - httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) - return - } - - users, err := handler.UserService.UsersByRole(portainer.AdministratorRole) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - if len(users) == 0 { - user := &portainer.User{ - Username: req.Username, - Role: portainer.AdministratorRole, - } - user.Password, err = handler.CryptoService.Hash(req.Password) - if err != nil { - httperror.WriteErrorResponse(w, portainer.ErrCryptoHashFailure, http.StatusBadRequest, handler.Logger) - return - } - - err = handler.UserService.CreateUser(user) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - } else { - httperror.WriteErrorResponse(w, portainer.ErrAdminAlreadyInitialized, http.StatusConflict, handler.Logger) - return - } -} - -// handleDeleteUser handles DELETE requests on /users/:id -func (handler *UserHandler) handleDeleteUser(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - id := vars["id"] - - userID, err := strconv.Atoi(id) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) - return - } - - tokenData, err := security.RetrieveTokenData(r) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - if tokenData.ID == portainer.UserID(userID) { - httperror.WriteErrorResponse(w, portainer.ErrAdminCannotRemoveSelf, http.StatusForbidden, handler.Logger) - return - } - - _, err = handler.UserService.User(portainer.UserID(userID)) - - if err == portainer.ErrUserNotFound { - httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) - return - } else if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - err = handler.UserService.DeleteUser(portainer.UserID(userID)) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - err = handler.TeamMembershipService.DeleteTeamMembershipByUserID(portainer.UserID(userID)) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } -} - -// handleGetMemberships handles GET requests on /users/:id/memberships -func (handler *UserHandler) handleGetMemberships(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - id := vars["id"] - - userID, err := strconv.Atoi(id) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) - return - } - - tokenData, err := security.RetrieveTokenData(r) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - if tokenData.Role != portainer.AdministratorRole && tokenData.ID != portainer.UserID(userID) { - httperror.WriteErrorResponse(w, portainer.ErrUnauthorized, http.StatusForbidden, handler.Logger) - return - } - - memberships, err := handler.TeamMembershipService.TeamMembershipsByUserID(portainer.UserID(userID)) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - encodeJSON(w, memberships, handler.Logger) -} diff --git a/api/http/handler/users/admin_check.go b/api/http/handler/users/admin_check.go new file mode 100644 index 000000000..1120c957a --- /dev/null +++ b/api/http/handler/users/admin_check.go @@ -0,0 +1,23 @@ +package users + +import ( + "net/http" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/response" +) + +// GET request on /api/users/admin/check +func (handler *Handler) adminCheck(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + users, err := handler.UserService.UsersByRole(portainer.AdministratorRole) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve users from the database", err} + } + + if len(users) == 0 { + return &httperror.HandlerError{http.StatusNotFound, "No administrator account found inside the database", portainer.ErrUserNotFound} + } + + return response.Empty(w) +} diff --git a/api/http/handler/users/admin_init.go b/api/http/handler/users/admin_init.go new file mode 100644 index 000000000..2ad394803 --- /dev/null +++ b/api/http/handler/users/admin_init.go @@ -0,0 +1,61 @@ +package users + +import ( + "net/http" + + "github.com/asaskevich/govalidator" + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" +) + +type adminInitPayload struct { + Username string + Password string +} + +func (payload *adminInitPayload) Validate(r *http.Request) error { + if govalidator.IsNull(payload.Username) || govalidator.Contains(payload.Username, " ") { + return portainer.Error("Invalid username. Must not contain any whitespace") + } + if govalidator.IsNull(payload.Password) { + return portainer.Error("Invalid password") + } + return nil +} + +// POST request on /api/users/admin/init +func (handler *Handler) adminInit(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + var payload adminInitPayload + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + users, err := handler.UserService.UsersByRole(portainer.AdministratorRole) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve users from the database", err} + } + + if len(users) != 0 { + return &httperror.HandlerError{http.StatusConflict, "Unable to retrieve users from the database", portainer.ErrAdminAlreadyInitialized} + } + + user := &portainer.User{ + Username: payload.Username, + Role: portainer.AdministratorRole, + } + + user.Password, err = handler.CryptoService.Hash(payload.Password) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to hash user password", portainer.ErrCryptoHashFailure} + } + + err = handler.UserService.CreateUser(user) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist user inside the database", err} + } + + return response.JSON(w, user) +} diff --git a/api/http/handler/users/handler.go b/api/http/handler/users/handler.go new file mode 100644 index 000000000..f6e4df727 --- /dev/null +++ b/api/http/handler/users/handler.go @@ -0,0 +1,53 @@ +package users + +import ( + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/security" + + "net/http" + + "github.com/gorilla/mux" +) + +func hideFields(user *portainer.User) { + user.Password = "" +} + +// Handler is the HTTP handler used to handle user operations. +type Handler struct { + *mux.Router + UserService portainer.UserService + TeamService portainer.TeamService + TeamMembershipService portainer.TeamMembershipService + ResourceControlService portainer.ResourceControlService + CryptoService portainer.CryptoService + SettingsService portainer.SettingsService +} + +// NewHandler creates a handler to manage user operations. +func NewHandler(bouncer *security.RequestBouncer) *Handler { + h := &Handler{ + Router: mux.NewRouter(), + } + h.Handle("/users", + bouncer.RestrictedAccess(httperror.LoggerHandler(h.userCreate))).Methods(http.MethodPost) + h.Handle("/users", + bouncer.RestrictedAccess(httperror.LoggerHandler(h.userList))).Methods(http.MethodGet) + h.Handle("/users/{id}", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.userInspect))).Methods(http.MethodGet) + h.Handle("/users/{id}", + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.userUpdate))).Methods(http.MethodPut) + h.Handle("/users/{id}", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.userDelete))).Methods(http.MethodDelete) + h.Handle("/users/{id}/memberships", + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.userMemberships))).Methods(http.MethodGet) + h.Handle("/users/{id}/passwd", + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.userPassword))).Methods(http.MethodPost) + h.Handle("/users/admin/check", + bouncer.PublicAccess(httperror.LoggerHandler(h.adminCheck))).Methods(http.MethodGet) + h.Handle("/users/admin/init", + bouncer.PublicAccess(httperror.LoggerHandler(h.adminInit))).Methods(http.MethodPost) + + return h +} diff --git a/api/http/handler/users/user_create.go b/api/http/handler/users/user_create.go new file mode 100644 index 000000000..7efba6d25 --- /dev/null +++ b/api/http/handler/users/user_create.go @@ -0,0 +1,84 @@ +package users + +import ( + "net/http" + + "github.com/asaskevich/govalidator" + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" + "github.com/portainer/portainer/http/security" +) + +type userCreatePayload struct { + Username string + Password string + Role int +} + +func (payload *userCreatePayload) Validate(r *http.Request) error { + if govalidator.IsNull(payload.Username) || govalidator.Contains(payload.Username, " ") { + return portainer.Error("Invalid username. Must not contain any whitespace") + } + + if payload.Role != 1 && payload.Role != 2 { + return portainer.Error("Invalid role value. Value must be one of: 1 (administrator) or 2 (regular user)") + } + return nil +} + +// POST request on /api/users +func (handler *Handler) userCreate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + var payload userCreatePayload + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} + } + + if !securityContext.IsAdmin && !securityContext.IsTeamLeader { + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to create user", portainer.ErrResourceAccessDenied} + } + + if securityContext.IsTeamLeader && payload.Role == 1 { + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to create administrator user", portainer.ErrResourceAccessDenied} + } + + user, err := handler.UserService.UserByUsername(payload.Username) + if err != nil && err != portainer.ErrUserNotFound { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve users from the database", err} + } + if user != nil { + return &httperror.HandlerError{http.StatusConflict, "Another user with the same username already exists", portainer.ErrUserAlreadyExists} + } + + user = &portainer.User{ + Username: payload.Username, + Role: portainer.UserRole(payload.Role), + } + + settings, err := handler.SettingsService.Settings() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve settings from the database", err} + } + + if settings.AuthenticationMethod == portainer.AuthenticationInternal { + user.Password, err = handler.CryptoService.Hash(payload.Password) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to hash user password", portainer.ErrCryptoHashFailure} + } + } + + err = handler.UserService.CreateUser(user) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist user inside the database", err} + } + + hideFields(user) + return response.JSON(w, user) +} diff --git a/api/http/handler/users/user_delete.go b/api/http/handler/users/user_delete.go new file mode 100644 index 000000000..5723bf387 --- /dev/null +++ b/api/http/handler/users/user_delete.go @@ -0,0 +1,47 @@ +package users + +import ( + "net/http" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" + "github.com/portainer/portainer/http/security" +) + +// DELETE request on /api/users/:id +func (handler *Handler) userDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + userID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid user identifier route variable", err} + } + + tokenData, err := security.RetrieveTokenData(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve user authentication token", err} + } + + if tokenData.ID == portainer.UserID(userID) { + return &httperror.HandlerError{http.StatusForbidden, "Cannot remove your own user account. Contact another administrator", portainer.ErrAdminCannotRemoveSelf} + } + + _, err = handler.UserService.User(portainer.UserID(userID)) + if err == portainer.ErrUserNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find a user with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a user with the specified identifier inside the database", err} + } + + err = handler.UserService.DeleteUser(portainer.UserID(userID)) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove user from the database", err} + } + + err = handler.TeamMembershipService.DeleteTeamMembershipByUserID(portainer.UserID(userID)) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove user memberships from the database", err} + } + + return response.Empty(w) +} diff --git a/api/http/handler/users/user_inspect.go b/api/http/handler/users/user_inspect.go new file mode 100644 index 000000000..c74d170d6 --- /dev/null +++ b/api/http/handler/users/user_inspect.go @@ -0,0 +1,28 @@ +package users + +import ( + "net/http" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" +) + +// GET request on /api/users/:id +func (handler *Handler) userInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + userID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid user identifier route variable", err} + } + + user, err := handler.UserService.User(portainer.UserID(userID)) + if err == portainer.ErrUserNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find a user with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a user with the specified identifier inside the database", err} + } + + hideFields(user) + return response.JSON(w, user) +} diff --git a/api/http/handler/users/user_list.go b/api/http/handler/users/user_list.go new file mode 100644 index 000000000..760c4ec54 --- /dev/null +++ b/api/http/handler/users/user_list.go @@ -0,0 +1,29 @@ +package users + +import ( + "net/http" + + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/response" + "github.com/portainer/portainer/http/security" +) + +// GET request on /api/users +func (handler *Handler) userList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + users, err := handler.UserService.Users() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve users from the database", err} + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} + } + + filteredUsers := security.FilterUsers(users, securityContext) + + for _, user := range filteredUsers { + hideFields(&user) + } + return response.JSON(w, filteredUsers) +} diff --git a/api/http/handler/users/user_memberships.go b/api/http/handler/users/user_memberships.go new file mode 100644 index 000000000..dfbb355ab --- /dev/null +++ b/api/http/handler/users/user_memberships.go @@ -0,0 +1,35 @@ +package users + +import ( + "net/http" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" + "github.com/portainer/portainer/http/security" +) + +// GET request on /api/users/:id/memberships +func (handler *Handler) userMemberships(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + userID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid user identifier route variable", err} + } + + tokenData, err := security.RetrieveTokenData(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve user authentication token", err} + } + + if tokenData.Role != portainer.AdministratorRole && tokenData.ID != portainer.UserID(userID) { + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to update user memberships", portainer.ErrUnauthorized} + } + + memberships, err := handler.TeamMembershipService.TeamMembershipsByUserID(portainer.UserID(userID)) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist membership changes inside the database", err} + } + + return response.JSON(w, memberships) +} diff --git a/api/http/handler/users/user_password.go b/api/http/handler/users/user_password.go new file mode 100644 index 000000000..073c10360 --- /dev/null +++ b/api/http/handler/users/user_password.go @@ -0,0 +1,57 @@ +package users + +import ( + "net/http" + + "github.com/asaskevich/govalidator" + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" +) + +type userPasswordPayload struct { + Password string +} + +func (payload *userPasswordPayload) Validate(r *http.Request) error { + if govalidator.IsNull(payload.Password) { + return portainer.Error("Invalid password") + } + return nil +} + +type userPasswordResponse struct { + Valid bool `json:"valid"` +} + +// POST request on /api/users/:id/passwd +func (handler *Handler) userPassword(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + userID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid user identifier route variable", err} + } + + var payload userPasswordPayload + err = request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + var password = payload.Password + + u, err := handler.UserService.User(portainer.UserID(userID)) + if err == portainer.ErrUserNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find a user with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a user with the specified identifier inside the database", err} + } + + valid := true + err = handler.CryptoService.CompareHashAndData(u.Password, password) + if err != nil { + valid = false + } + + return response.JSON(w, &userPasswordResponse{Valid: valid}) +} diff --git a/api/http/handler/users/user_update.go b/api/http/handler/users/user_update.go new file mode 100644 index 000000000..4a01d0e37 --- /dev/null +++ b/api/http/handler/users/user_update.go @@ -0,0 +1,75 @@ +package users + +import ( + "net/http" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" + "github.com/portainer/portainer/http/security" +) + +type userUpdatePayload struct { + Password string + Role int +} + +func (payload *userUpdatePayload) Validate(r *http.Request) error { + if payload.Role != 0 && payload.Role != 1 && payload.Role != 2 { + return portainer.Error("Invalid role value. Value must be one of: 1 (administrator) or 2 (regular user)") + } + return nil +} + +// PUT request on /api/users/:id +func (handler *Handler) userUpdate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + userID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid user identifier route variable", err} + } + + tokenData, err := security.RetrieveTokenData(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve user authentication token", err} + } + + if tokenData.Role != portainer.AdministratorRole && tokenData.ID != portainer.UserID(userID) { + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to update user", portainer.ErrUnauthorized} + } + + var payload userUpdatePayload + err = request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + if tokenData.Role != portainer.AdministratorRole && payload.Role != 0 { + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to update user to administrator role", portainer.ErrResourceAccessDenied} + } + + user, err := handler.UserService.User(portainer.UserID(userID)) + if err == portainer.ErrUserNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find a user with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a user with the specified identifier inside the database", err} + } + + if payload.Password != "" { + user.Password, err = handler.CryptoService.Hash(payload.Password) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to hash user password", portainer.ErrCryptoHashFailure} + } + } + + if payload.Role != 0 { + user.Role = portainer.UserRole(payload.Role) + } + + err = handler.UserService.UpdateUser(user.ID, user) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist user changes inside the database", err} + } + + return response.JSON(w, user) +} diff --git a/api/http/handler/websocket/handler.go b/api/http/handler/websocket/handler.go new file mode 100644 index 000000000..34dcce593 --- /dev/null +++ b/api/http/handler/websocket/handler.go @@ -0,0 +1,28 @@ +package websocket + +import ( + "net/http" + + "github.com/gorilla/mux" + "github.com/gorilla/websocket" + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" +) + +// Handler is the HTTP handler used to handle websocket operations. +type Handler struct { + *mux.Router + EndpointService portainer.EndpointService + SignatureService portainer.DigitalSignatureService + connectionUpgrader websocket.Upgrader +} + +// NewHandler creates a handler to manage websocket operations. +func NewHandler() *Handler { + h := &Handler{ + Router: mux.NewRouter(), + connectionUpgrader: websocket.Upgrader{}, + } + h.Handle("/websocket/exec", httperror.LoggerHandler(h.websocketExec)).Methods(http.MethodGet) + return h +} diff --git a/api/http/handler/websocket.go b/api/http/handler/websocket/websocket_exec.go similarity index 73% rename from api/http/handler/websocket.go rename to api/http/handler/websocket/websocket_exec.go index 629e60390..98cb6d983 100644 --- a/api/http/handler/websocket.go +++ b/api/http/handler/websocket/websocket_exec.go @@ -1,4 +1,4 @@ -package handler +package websocket import ( "bufio" @@ -6,94 +6,68 @@ import ( "crypto/tls" "encoding/json" "fmt" - "log" "net" "net/http" "net/http/httputil" "net/url" - "os" - "strconv" "time" - "github.com/gorilla/mux" "github.com/gorilla/websocket" "github.com/koding/websocketproxy" "github.com/portainer/portainer" "github.com/portainer/portainer/crypto" httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" ) -type ( - // WebSocketHandler represents an HTTP API handler for proxying requests to a web socket. - WebSocketHandler struct { - *mux.Router - Logger *log.Logger - EndpointService portainer.EndpointService - SignatureService portainer.DigitalSignatureService - connectionUpgrader websocket.Upgrader - } - - webSocketExecRequestParams struct { - execID string - nodeName string - endpoint *portainer.Endpoint - } - - execStartOperationPayload struct { - Tty bool - Detach bool - } -) - -// NewWebSocketHandler returns a new instance of WebSocketHandler. -func NewWebSocketHandler() *WebSocketHandler { - h := &WebSocketHandler{ - Router: mux.NewRouter(), - Logger: log.New(os.Stderr, "", log.LstdFlags), - connectionUpgrader: websocket.Upgrader{}, - } - h.HandleFunc("/websocket/exec", h.handleWebsocketExec).Methods(http.MethodGet) - return h +type webSocketExecRequestParams struct { + execID string + nodeName string + endpoint *portainer.Endpoint } -// handleWebsocketExec handles GET requests on /websocket/exec?id=&endpointId=&nodeName= +type execStartOperationPayload struct { + Tty bool + Detach bool +} + +// websocketExec handles GET requests on /websocket/exec?id=&endpointId=&nodeName= // If the nodeName query parameter is present, the request will be proxied to the underlying agent endpoint. // If the nodeName query parameter is not specified, the request will be upgraded to the websocket protocol and // an ExecStart operation HTTP request will be created and hijacked. -func (handler *WebSocketHandler) handleWebsocketExec(w http.ResponseWriter, r *http.Request) { - paramExecID := r.FormValue("id") - paramEndpointID := r.FormValue("endpointId") - if paramExecID == "" || paramEndpointID == "" { - httperror.WriteErrorResponse(w, ErrInvalidQueryFormat, http.StatusBadRequest, handler.Logger) - return +func (handler *Handler) websocketExec(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + execID, err := request.RetrieveQueryParameter(r, "id", false) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: id", err} } - endpointID, err := strconv.Atoi(paramEndpointID) + endpointID, err := request.RetrieveNumericQueryParameter(r, "endpointId", false) if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) - return + return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: endpointId", err} } endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID)) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return + if err == portainer.ErrEndpointNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find the endpoint associated to the stack inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find the endpoint associated to the stack inside the database", err} } params := &webSocketExecRequestParams{ endpoint: endpoint, - execID: paramExecID, + execID: execID, nodeName: r.FormValue("nodeName"), } err = handler.handleRequest(w, r, params) if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return + return &httperror.HandlerError{http.StatusInternalServerError, "An error occured during websocket exec operation", err} } + + return nil } -func (handler *WebSocketHandler) handleRequest(w http.ResponseWriter, r *http.Request, params *webSocketExecRequestParams) error { +func (handler *Handler) handleRequest(w http.ResponseWriter, r *http.Request, params *webSocketExecRequestParams) error { r.Header.Del("Origin") if params.nodeName != "" { @@ -109,7 +83,7 @@ func (handler *WebSocketHandler) handleRequest(w http.ResponseWriter, r *http.Re return hijackExecStartOperation(websocketConn, params.endpoint, params.execID) } -func (handler *WebSocketHandler) proxyWebsocketRequest(w http.ResponseWriter, r *http.Request, params *webSocketExecRequestParams) error { +func (handler *Handler) proxyWebsocketRequest(w http.ResponseWriter, r *http.Request, params *webSocketExecRequestParams) error { agentURL, err := url.Parse(params.endpoint.URL) if err != nil { return err diff --git a/api/http/proxy/access_control.go b/api/http/proxy/access_control.go index 408ff8c7d..331b9d0da 100644 --- a/api/http/proxy/access_control.go +++ b/api/http/proxy/access_control.go @@ -10,7 +10,7 @@ type ( } ) -// applyResourceAccessControl returns an optionally decorated object as the first return value and the +// applyResourceAccessControlFromLabel returns an optionally decorated object as the first return value and the // access level for the user (granted or denied) as the second return value. // It will retrieve an identifier from the labels object. If an identifier exists, it will check for // an existing resource control associated to it. diff --git a/api/http/proxy/containers.go b/api/http/proxy/containers.go index b05149940..9da66e21f 100644 --- a/api/http/proxy/containers.go +++ b/api/http/proxy/containers.go @@ -8,10 +8,11 @@ import ( const ( // ErrDockerContainerIdentifierNotFound defines an error raised when Portainer is unable to find a container identifier - ErrDockerContainerIdentifierNotFound = portainer.Error("Docker container identifier not found") - containerIdentifier = "Id" - containerLabelForServiceIdentifier = "com.docker.swarm.service.id" - containerLabelForStackIdentifier = "com.docker.stack.namespace" + ErrDockerContainerIdentifierNotFound = portainer.Error("Docker container identifier not found") + containerIdentifier = "Id" + containerLabelForServiceIdentifier = "com.docker.swarm.service.id" + containerLabelForSwarmStackIdentifier = "com.docker.stack.namespace" + containerLabelForComposeStackIdentifier = "com.docker.compose.project" ) // containerListOperation extracts the response as a JSON object, loop through the containers array @@ -71,7 +72,12 @@ func containerInspectOperation(response *http.Response, executor *operationExecu return rewriteAccessDeniedResponse(response) } - responseObject, access = applyResourceAccessControlFromLabel(containerLabels, responseObject, containerLabelForStackIdentifier, executor.operationContext) + responseObject, access = applyResourceAccessControlFromLabel(containerLabels, responseObject, containerLabelForSwarmStackIdentifier, executor.operationContext) + if !access { + return rewriteAccessDeniedResponse(response) + } + + responseObject, access = applyResourceAccessControlFromLabel(containerLabels, responseObject, containerLabelForComposeStackIdentifier, executor.operationContext) if !access { return rewriteAccessDeniedResponse(response) } @@ -117,7 +123,8 @@ func decorateContainerList(containerData []interface{}, resourceControls []porta containerLabels := extractContainerLabelsFromContainerListObject(containerObject) containerObject = decorateResourceWithAccessControlFromLabel(containerLabels, containerObject, containerLabelForServiceIdentifier, resourceControls) - containerObject = decorateResourceWithAccessControlFromLabel(containerLabels, containerObject, containerLabelForStackIdentifier, resourceControls) + containerObject = decorateResourceWithAccessControlFromLabel(containerLabels, containerObject, containerLabelForSwarmStackIdentifier, resourceControls) + containerObject = decorateResourceWithAccessControlFromLabel(containerLabels, containerObject, containerLabelForComposeStackIdentifier, resourceControls) decoratedContainerData = append(decoratedContainerData, containerObject) } @@ -143,11 +150,14 @@ func filterContainerList(containerData []interface{}, context *restrictedOperati containerObject, access := applyResourceAccessControl(containerObject, containerID, context) if access { containerLabels := extractContainerLabelsFromContainerListObject(containerObject) - containerObject, access = applyResourceAccessControlFromLabel(containerLabels, containerObject, containerLabelForServiceIdentifier, context) + containerObject, access = applyResourceAccessControlFromLabel(containerLabels, containerObject, containerLabelForComposeStackIdentifier, context) if access { - containerObject, access = applyResourceAccessControlFromLabel(containerLabels, containerObject, containerLabelForStackIdentifier, context) + containerObject, access = applyResourceAccessControlFromLabel(containerLabels, containerObject, containerLabelForServiceIdentifier, context) if access { - filteredContainerData = append(filteredContainerData, containerObject) + containerObject, access = applyResourceAccessControlFromLabel(containerLabels, containerObject, containerLabelForSwarmStackIdentifier, context) + if access { + filteredContainerData = append(filteredContainerData, containerObject) + } } } } diff --git a/api/http/proxy/docker_transport.go b/api/http/proxy/docker_transport.go index 9effb54d8..8fb20d3f0 100644 --- a/api/http/proxy/docker_transport.go +++ b/api/http/proxy/docker_transport.go @@ -3,6 +3,7 @@ package proxy import ( "encoding/base64" "encoding/json" + "log" "net/http" "path" "regexp" @@ -303,6 +304,7 @@ func (p *proxyTransport) replaceRegistryAuthenticationHeader(request *http.Reque } authenticationHeader := createRegistryAuthenticationHeader(originalHeaderData.Serveraddress, accessContext) + log.Printf("Header: %+v", authenticationHeader) headerData, err := json.Marshal(authenticationHeader) if err != nil { diff --git a/api/http/proxy/manager.go b/api/http/proxy/manager.go index f2a691634..88acdf515 100644 --- a/api/http/proxy/manager.go +++ b/api/http/proxy/manager.go @@ -99,7 +99,6 @@ func (manager *Manager) DeleteProxy(key string) { // CreateAndRegisterExtensionProxy creates a new HTTP reverse proxy for an extension and adds it to the registered proxies. func (manager *Manager) CreateAndRegisterExtensionProxy(key, extensionAPIURL string) (http.Handler, error) { - extensionURL, err := url.Parse(extensionAPIURL) if err != nil { return nil, err diff --git a/api/http/proxy/socket.go b/api/http/proxy/socket.go index 5a9158492..58ccdec27 100644 --- a/api/http/proxy/socket.go +++ b/api/http/proxy/socket.go @@ -3,6 +3,7 @@ package proxy // unixSocketHandler represents a handler to proxy HTTP requests via a unix:// socket import ( "io" + "log" "net/http" httperror "github.com/portainer/portainer/http/error" @@ -24,7 +25,7 @@ func (proxy *socketProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { if res != nil && res.StatusCode != 0 { code = res.StatusCode } - httperror.WriteErrorResponse(w, err, code, nil) + httperror.WriteError(w, code, "Unable to proxy the request via the Docker socket", err) return } defer res.Body.Close() @@ -38,6 +39,6 @@ func (proxy *socketProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { w.WriteHeader(res.StatusCode) if _, err := io.Copy(w, res.Body); err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, nil) + log.Printf("proxy error: %s\n", err) } } diff --git a/api/http/request/request.go b/api/http/request/request.go new file mode 100644 index 000000000..16ba4590b --- /dev/null +++ b/api/http/request/request.go @@ -0,0 +1,160 @@ +package request + +import ( + "encoding/json" + "io/ioutil" + "net/http" + "strconv" + + "github.com/gorilla/mux" + "github.com/portainer/portainer" +) + +const ( + // ErrInvalidQueryParameter defines an error raised when a mandatory query parameter has an invalid value. + ErrInvalidQueryParameter = portainer.Error("Invalid query parameter") + // errInvalidRequestURL defines an error raised when the data sent in the query or the URL is invalid + errInvalidRequestURL = portainer.Error("Invalid request URL") + // errMissingQueryParameter defines an error raised when a mandatory query parameter is missing. + errMissingQueryParameter = portainer.Error("Missing query parameter") + // errMissingFormDataValue defines an error raised when a mandatory form data value is missing. + errMissingFormDataValue = portainer.Error("Missing form data value") +) + +// PayloadValidation is an interface used to validate the payload of a request. +type PayloadValidation interface { + Validate(request *http.Request) error +} + +// DecodeAndValidateJSONPayload decodes the body of the request into an object +// implementing the PayloadValidation interface. +// It also triggers a validation of object content. +func DecodeAndValidateJSONPayload(request *http.Request, v PayloadValidation) error { + if err := json.NewDecoder(request.Body).Decode(v); err != nil { + return err + } + return v.Validate(request) +} + +// RetrieveMultiPartFormFile returns the content of an uploaded file (form data) as bytes. +func RetrieveMultiPartFormFile(request *http.Request, requestParameter string) ([]byte, error) { + file, _, err := request.FormFile(requestParameter) + if err != nil { + return nil, err + } + defer file.Close() + + fileContent, err := ioutil.ReadAll(file) + if err != nil { + return nil, err + } + return fileContent, nil +} + +// RetrieveMultiPartFormJSONValue decodes the value of some form data as a JSON object into the target parameter. +// If optional is set to true, will not return an error when the form data value is not found. +func RetrieveMultiPartFormJSONValue(request *http.Request, name string, target interface{}, optional bool) error { + value, err := RetrieveMultiPartFormValue(request, name, optional) + if err != nil { + return err + } + if value == "" { + return nil + } + return json.Unmarshal([]byte(value), target) +} + +// RetrieveMultiPartFormValue returns the value of some form data as a string. +// If optional is set to true, will not return an error when the form data value is not found. +func RetrieveMultiPartFormValue(request *http.Request, name string, optional bool) (string, error) { + value := request.FormValue(name) + if value == "" && !optional { + return "", errMissingFormDataValue + } + return value, nil +} + +// RetrieveNumericMultiPartFormValue returns the value of some form data as an integer. +// If optional is set to true, will not return an error when the form data value is not found. +func RetrieveNumericMultiPartFormValue(request *http.Request, name string, optional bool) (int, error) { + value, err := RetrieveMultiPartFormValue(request, name, optional) + if err != nil { + return 0, err + } + return strconv.Atoi(value) +} + +// RetrieveBooleanMultiPartFormValue returns the value of some form data as a boolean. +// If optional is set to true, will not return an error when the form data value is not found. +func RetrieveBooleanMultiPartFormValue(request *http.Request, name string, optional bool) (bool, error) { + value, err := RetrieveMultiPartFormValue(request, name, optional) + if err != nil { + return false, err + } + return value == "true", nil +} + +// RetrieveRouteVariableValue returns the value of a route variable as a string. +func RetrieveRouteVariableValue(request *http.Request, name string) (string, error) { + routeVariables := mux.Vars(request) + if routeVariables == nil { + return "", errInvalidRequestURL + } + routeVar := routeVariables[name] + if routeVar == "" { + return "", errInvalidRequestURL + } + return routeVar, nil +} + +// RetrieveNumericRouteVariableValue returns the value of a route variable as an integer. +func RetrieveNumericRouteVariableValue(request *http.Request, name string) (int, error) { + routeVar, err := RetrieveRouteVariableValue(request, name) + if err != nil { + return 0, err + } + return strconv.Atoi(routeVar) +} + +// RetrieveQueryParameter returns the value of a query parameter as a string. +// If optional is set to true, will not return an error when the query parameter is not found. +func RetrieveQueryParameter(request *http.Request, name string, optional bool) (string, error) { + queryParameter := request.FormValue(name) + if queryParameter == "" && !optional { + return "", errMissingQueryParameter + } + return queryParameter, nil +} + +// RetrieveNumericQueryParameter returns the value of a query parameter as an integer. +// If optional is set to true, will not return an error when the query parameter is not found. +func RetrieveNumericQueryParameter(request *http.Request, name string, optional bool) (int, error) { + queryParameter, err := RetrieveQueryParameter(request, name, optional) + if err != nil { + return 0, err + } + return strconv.Atoi(queryParameter) +} + +// RetrieveBooleanQueryParameter returns the value of a query parameter as a boolean. +// If optional is set to true, will not return an error when the query parameter is not found. +func RetrieveBooleanQueryParameter(request *http.Request, name string, optional bool) (bool, error) { + queryParameter, err := RetrieveQueryParameter(request, name, optional) + if err != nil { + return false, err + } + return queryParameter == "true", nil +} + +// RetrieveJSONQueryParameter decodes the value of a query paramater as a JSON object into the target parameter. +// If optional is set to true, will not return an error when the query parameter is not found. +func RetrieveJSONQueryParameter(request *http.Request, name string, target interface{}, optional bool) error { + queryParameter, err := RetrieveQueryParameter(request, name, optional) + if err != nil { + return err + } + if queryParameter == "" { + return nil + } + return json.Unmarshal([]byte(queryParameter), target) +} diff --git a/api/http/response/response.go b/api/http/response/response.go new file mode 100644 index 000000000..1334d4d7e --- /dev/null +++ b/api/http/response/response.go @@ -0,0 +1,32 @@ +package response + +import ( + "encoding/json" + "net/http" + + httperror "github.com/portainer/portainer/http/error" +) + +// JSON encodes data to rw in JSON format. Returns a pointer to a +// HandlerError if encoding fails. +func JSON(rw http.ResponseWriter, data interface{}) *httperror.HandlerError { + rw.Header().Set("Content-Type", "application/json") + err := json.NewEncoder(rw).Encode(data) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to write JSON response", err} + } + return nil +} + +// Empty merely sets the response code to NoContent (204). +func Empty(rw http.ResponseWriter) *httperror.HandlerError { + rw.WriteHeader(http.StatusNoContent) + return nil +} + +// Bytes write data into rw. It also allows to set the Content-Type header. +func Bytes(rw http.ResponseWriter, data []byte, contentType string) *httperror.HandlerError { + rw.Header().Set("Content-Type", contentType) + rw.Write(data) + return nil +} diff --git a/api/http/security/bouncer.go b/api/http/security/bouncer.go index 76b47aaea..1e3e7d522 100644 --- a/api/http/security/bouncer.go +++ b/api/http/security/bouncer.go @@ -85,13 +85,13 @@ func (bouncer *RequestBouncer) mwUpgradeToRestrictedRequest(next http.Handler) h return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { tokenData, err := RetrieveTokenData(r) if err != nil { - httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, nil) + httperror.WriteError(w, http.StatusForbidden, "Access denied", portainer.ErrResourceAccessDenied) return } requestContext, err := bouncer.newRestrictedContextRequest(tokenData.ID, tokenData.Role) if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, nil) + httperror.WriteError(w, http.StatusInternalServerError, "Unable to create restricted request context ", err) return } @@ -105,7 +105,7 @@ func mwCheckAdministratorRole(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { tokenData, err := RetrieveTokenData(r) if err != nil || tokenData.Role != portainer.AdministratorRole { - httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, nil) + httperror.WriteError(w, http.StatusForbidden, "Access denied", portainer.ErrResourceAccessDenied) return } @@ -128,23 +128,23 @@ func (bouncer *RequestBouncer) mwCheckAuthentication(next http.Handler) http.Han } if token == "" { - httperror.WriteErrorResponse(w, portainer.ErrUnauthorized, http.StatusUnauthorized, nil) + httperror.WriteError(w, http.StatusUnauthorized, "Unauthorized", portainer.ErrUnauthorized) return } var err error tokenData, err = bouncer.jwtService.ParseAndVerifyToken(token) if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusUnauthorized, nil) + httperror.WriteError(w, http.StatusUnauthorized, "Invalid JWT token", err) return } _, err = bouncer.userService.User(tokenData.ID) if err != nil && err == portainer.ErrUserNotFound { - httperror.WriteErrorResponse(w, portainer.ErrUnauthorized, http.StatusUnauthorized, nil) + httperror.WriteError(w, http.StatusUnauthorized, "Unauthorized", portainer.ErrUnauthorized) return } else if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, nil) + httperror.WriteError(w, http.StatusInternalServerError, "Unable to retrieve users from the database", err) return } } else { diff --git a/api/http/security/filter.go b/api/http/security/filter.go index 5c1b0774f..71bd314b4 100644 --- a/api/http/security/filter.go +++ b/api/http/security/filter.go @@ -62,8 +62,7 @@ func FilterUsers(users []portainer.User, context *RestrictedRequestContext) []po // FilterRegistries filters registries based on user role and team memberships. // Non administrator users only have access to authorized registries. -func FilterRegistries(registries []portainer.Registry, context *RestrictedRequestContext) ([]portainer.Registry, error) { - +func FilterRegistries(registries []portainer.Registry, context *RestrictedRequestContext) []portainer.Registry { filteredRegistries := registries if !context.IsAdmin { filteredRegistries = make([]portainer.Registry, 0) @@ -75,12 +74,12 @@ func FilterRegistries(registries []portainer.Registry, context *RestrictedReques } } - return filteredRegistries, nil + return filteredRegistries } // FilterEndpoints filters endpoints based on user role and team memberships. // Non administrator users only have access to authorized endpoints (can be inherited via endoint groups). -func FilterEndpoints(endpoints []portainer.Endpoint, groups []portainer.EndpointGroup, context *RestrictedRequestContext) ([]portainer.Endpoint, error) { +func FilterEndpoints(endpoints []portainer.Endpoint, groups []portainer.EndpointGroup, context *RestrictedRequestContext) []portainer.Endpoint { filteredEndpoints := endpoints if !context.IsAdmin { @@ -95,12 +94,12 @@ func FilterEndpoints(endpoints []portainer.Endpoint, groups []portainer.Endpoint } } - return filteredEndpoints, nil + return filteredEndpoints } // FilterEndpointGroups filters endpoint groups based on user role and team memberships. // Non administrator users only have access to authorized endpoint groups. -func FilterEndpointGroups(endpointGroups []portainer.EndpointGroup, context *RestrictedRequestContext) ([]portainer.EndpointGroup, error) { +func FilterEndpointGroups(endpointGroups []portainer.EndpointGroup, context *RestrictedRequestContext) []portainer.EndpointGroup { filteredEndpointGroups := endpointGroups if !context.IsAdmin { @@ -113,7 +112,7 @@ func FilterEndpointGroups(endpointGroups []portainer.EndpointGroup, context *Res } } - return filteredEndpointGroups, nil + return filteredEndpointGroups } func getAssociatedGroup(endpoint *portainer.Endpoint, groups []portainer.EndpointGroup) *portainer.EndpointGroup { diff --git a/api/http/security/rate_limiter.go b/api/http/security/rate_limiter.go index 0eb89e0c1..27ab2523a 100644 --- a/api/http/security/rate_limiter.go +++ b/api/http/security/rate_limiter.go @@ -30,7 +30,7 @@ func (limiter *RateLimiter) LimitAccess(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ip := StripAddrPort(r.RemoteAddr) if banned := limiter.Inc(ip); banned == true { - httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, nil) + httperror.WriteError(w, http.StatusForbidden, "Access denied", portainer.ErrResourceAccessDenied) return } next.ServeHTTP(w, r) diff --git a/api/http/server.go b/api/http/server.go index c32374e20..f0b1cdbda 100644 --- a/api/http/server.go +++ b/api/http/server.go @@ -5,7 +5,23 @@ import ( "github.com/portainer/portainer" "github.com/portainer/portainer/http/handler" - "github.com/portainer/portainer/http/handler/extensions" + "github.com/portainer/portainer/http/handler/auth" + "github.com/portainer/portainer/http/handler/dockerhub" + "github.com/portainer/portainer/http/handler/endpointgroups" + "github.com/portainer/portainer/http/handler/endpointproxy" + "github.com/portainer/portainer/http/handler/endpoints" + "github.com/portainer/portainer/http/handler/file" + "github.com/portainer/portainer/http/handler/registries" + "github.com/portainer/portainer/http/handler/resourcecontrols" + "github.com/portainer/portainer/http/handler/settings" + "github.com/portainer/portainer/http/handler/stacks" + "github.com/portainer/portainer/http/handler/status" + "github.com/portainer/portainer/http/handler/teammemberships" + "github.com/portainer/portainer/http/handler/teams" + "github.com/portainer/portainer/http/handler/templates" + "github.com/portainer/portainer/http/handler/upload" + "github.com/portainer/portainer/http/handler/users" + "github.com/portainer/portainer/http/handler/websocket" "github.com/portainer/portainer/http/proxy" "github.com/portainer/portainer/http/security" @@ -33,7 +49,8 @@ type Server struct { RegistryService portainer.RegistryService DockerHubService portainer.DockerHubService StackService portainer.StackService - StackManager portainer.StackManager + SwarmStackManager portainer.SwarmStackManager + ComposeStackManager portainer.ComposeStackManager LDAPService portainer.LDAPService GitService portainer.GitService SignatureService portainer.DigitalSignatureService @@ -57,100 +74,102 @@ func (server *Server) Start() error { proxyManager := proxy.NewManager(proxyManagerParameters) rateLimiter := security.NewRateLimiter(10, 1*time.Second, 1*time.Hour) - var fileHandler = handler.NewFileHandler(filepath.Join(server.AssetsPath, "public")) - var authHandler = handler.NewAuthHandler(requestBouncer, rateLimiter, server.AuthDisabled) + var authHandler = auth.NewHandler(requestBouncer, rateLimiter, server.AuthDisabled) authHandler.UserService = server.UserService authHandler.CryptoService = server.CryptoService authHandler.JWTService = server.JWTService authHandler.LDAPService = server.LDAPService authHandler.SettingsService = server.SettingsService - var userHandler = handler.NewUserHandler(requestBouncer) + + var dockerHubHandler = dockerhub.NewHandler(requestBouncer) + dockerHubHandler.DockerHubService = server.DockerHubService + + var endpointHandler = endpoints.NewHandler(requestBouncer, server.EndpointManagement) + endpointHandler.EndpointService = server.EndpointService + endpointHandler.EndpointGroupService = server.EndpointGroupService + endpointHandler.FileService = server.FileService + endpointHandler.ProxyManager = proxyManager + + var endpointGroupHandler = endpointgroups.NewHandler(requestBouncer) + endpointGroupHandler.EndpointGroupService = server.EndpointGroupService + endpointGroupHandler.EndpointService = server.EndpointService + + var endpointProxyHandler = endpointproxy.NewHandler(requestBouncer) + endpointProxyHandler.EndpointService = server.EndpointService + endpointProxyHandler.EndpointGroupService = server.EndpointGroupService + endpointProxyHandler.TeamMembershipService = server.TeamMembershipService + endpointProxyHandler.ProxyManager = proxyManager + + var fileHandler = file.NewHandler(filepath.Join(server.AssetsPath, "public")) + + var registryHandler = registries.NewHandler(requestBouncer) + registryHandler.RegistryService = server.RegistryService + + var resourceControlHandler = resourcecontrols.NewHandler(requestBouncer) + resourceControlHandler.ResourceControlService = server.ResourceControlService + + var settingsHandler = settings.NewHandler(requestBouncer) + settingsHandler.SettingsService = server.SettingsService + settingsHandler.LDAPService = server.LDAPService + settingsHandler.FileService = server.FileService + + var stackHandler = stacks.NewHandler(requestBouncer) + stackHandler.FileService = server.FileService + stackHandler.StackService = server.StackService + stackHandler.EndpointService = server.EndpointService + stackHandler.EndpointGroupService = server.EndpointGroupService + stackHandler.TeamMembershipService = server.TeamMembershipService + stackHandler.ResourceControlService = server.ResourceControlService + stackHandler.SwarmStackManager = server.SwarmStackManager + stackHandler.ComposeStackManager = server.ComposeStackManager + stackHandler.GitService = server.GitService + stackHandler.RegistryService = server.RegistryService + stackHandler.DockerHubService = server.DockerHubService + + var teamHandler = teams.NewHandler(requestBouncer) + teamHandler.TeamService = server.TeamService + teamHandler.TeamMembershipService = server.TeamMembershipService + + var teamMembershipHandler = teammemberships.NewHandler(requestBouncer) + teamMembershipHandler.TeamMembershipService = server.TeamMembershipService + var statusHandler = status.NewHandler(requestBouncer, server.Status) + + var templatesHandler = templates.NewHandler(requestBouncer) + templatesHandler.SettingsService = server.SettingsService + + var uploadHandler = upload.NewHandler(requestBouncer) + uploadHandler.FileService = server.FileService + + var userHandler = users.NewHandler(requestBouncer) userHandler.UserService = server.UserService userHandler.TeamService = server.TeamService userHandler.TeamMembershipService = server.TeamMembershipService userHandler.CryptoService = server.CryptoService userHandler.ResourceControlService = server.ResourceControlService userHandler.SettingsService = server.SettingsService - var teamHandler = handler.NewTeamHandler(requestBouncer) - teamHandler.TeamService = server.TeamService - teamHandler.TeamMembershipService = server.TeamMembershipService - var teamMembershipHandler = handler.NewTeamMembershipHandler(requestBouncer) - teamMembershipHandler.TeamMembershipService = server.TeamMembershipService - var statusHandler = handler.NewStatusHandler(requestBouncer, server.Status) - var settingsHandler = handler.NewSettingsHandler(requestBouncer) - settingsHandler.SettingsService = server.SettingsService - settingsHandler.LDAPService = server.LDAPService - settingsHandler.FileService = server.FileService - var templatesHandler = handler.NewTemplatesHandler(requestBouncer) - templatesHandler.SettingsService = server.SettingsService - var dockerHandler = handler.NewDockerHandler(requestBouncer) - dockerHandler.EndpointService = server.EndpointService - dockerHandler.EndpointGroupService = server.EndpointGroupService - dockerHandler.TeamMembershipService = server.TeamMembershipService - dockerHandler.ProxyManager = proxyManager - var azureHandler = handler.NewAzureHandler(requestBouncer) - azureHandler.EndpointService = server.EndpointService - azureHandler.EndpointGroupService = server.EndpointGroupService - azureHandler.TeamMembershipService = server.TeamMembershipService - azureHandler.ProxyManager = proxyManager - var websocketHandler = handler.NewWebSocketHandler() + + var websocketHandler = websocket.NewHandler() websocketHandler.EndpointService = server.EndpointService websocketHandler.SignatureService = server.SignatureService - var endpointHandler = handler.NewEndpointHandler(requestBouncer, server.EndpointManagement) - endpointHandler.EndpointService = server.EndpointService - endpointHandler.EndpointGroupService = server.EndpointGroupService - endpointHandler.FileService = server.FileService - endpointHandler.ProxyManager = proxyManager - var endpointGroupHandler = handler.NewEndpointGroupHandler(requestBouncer) - endpointGroupHandler.EndpointGroupService = server.EndpointGroupService - endpointGroupHandler.EndpointService = server.EndpointService - var registryHandler = handler.NewRegistryHandler(requestBouncer) - registryHandler.RegistryService = server.RegistryService - var dockerHubHandler = handler.NewDockerHubHandler(requestBouncer) - dockerHubHandler.DockerHubService = server.DockerHubService - var resourceHandler = handler.NewResourceHandler(requestBouncer) - resourceHandler.ResourceControlService = server.ResourceControlService - var uploadHandler = handler.NewUploadHandler(requestBouncer) - uploadHandler.FileService = server.FileService - var stackHandler = handler.NewStackHandler(requestBouncer) - stackHandler.FileService = server.FileService - stackHandler.StackService = server.StackService - stackHandler.EndpointService = server.EndpointService - stackHandler.ResourceControlService = server.ResourceControlService - stackHandler.StackManager = server.StackManager - stackHandler.GitService = server.GitService - stackHandler.RegistryService = server.RegistryService - stackHandler.DockerHubService = server.DockerHubService - var extensionHandler = handler.NewExtensionHandler(requestBouncer) - extensionHandler.EndpointService = server.EndpointService - extensionHandler.ProxyManager = proxyManager - var storidgeHandler = extensions.NewStoridgeHandler(requestBouncer) - storidgeHandler.EndpointService = server.EndpointService - storidgeHandler.EndpointGroupService = server.EndpointGroupService - storidgeHandler.TeamMembershipService = server.TeamMembershipService - storidgeHandler.ProxyManager = proxyManager server.Handler = &handler.Handler{ - AuthHandler: authHandler, - UserHandler: userHandler, - TeamHandler: teamHandler, - TeamMembershipHandler: teamMembershipHandler, - EndpointHandler: endpointHandler, - EndpointGroupHandler: endpointGroupHandler, - RegistryHandler: registryHandler, - DockerHubHandler: dockerHubHandler, - ResourceHandler: resourceHandler, - SettingsHandler: settingsHandler, - StatusHandler: statusHandler, - StackHandler: stackHandler, - TemplatesHandler: templatesHandler, - DockerHandler: dockerHandler, - AzureHandler: azureHandler, - WebSocketHandler: websocketHandler, - FileHandler: fileHandler, - UploadHandler: uploadHandler, - ExtensionHandler: extensionHandler, - StoridgeHandler: storidgeHandler, + AuthHandler: authHandler, + DockerHubHandler: dockerHubHandler, + EndpointGroupHandler: endpointGroupHandler, + EndpointHandler: endpointHandler, + EndpointProxyHandler: endpointProxyHandler, + FileHandler: fileHandler, + RegistryHandler: registryHandler, + ResourceControlHandler: resourceControlHandler, + SettingsHandler: settingsHandler, + StatusHandler: statusHandler, + StackHandler: stackHandler, + TeamHandler: teamHandler, + TeamMembershipHandler: teamMembershipHandler, + TemplatesHandler: templatesHandler, + UploadHandler: uploadHandler, + UserHandler: userHandler, + WebSocketHandler: websocketHandler, } if server.SSL { diff --git a/api/libcompose/compose_stack.go b/api/libcompose/compose_stack.go new file mode 100644 index 000000000..33d582f90 --- /dev/null +++ b/api/libcompose/compose_stack.go @@ -0,0 +1,91 @@ +package libcompose + +import ( + "context" + "path" + "path/filepath" + + "github.com/portainer/libcompose/docker" + "github.com/portainer/libcompose/docker/client" + "github.com/portainer/libcompose/docker/ctx" + "github.com/portainer/libcompose/lookup" + "github.com/portainer/libcompose/project" + "github.com/portainer/libcompose/project/options" + "github.com/portainer/portainer" +) + +// ComposeStackManager represents a service for managing compose stacks. +type ComposeStackManager struct { + dataPath string +} + +// NewComposeStackManager initializes a new ComposeStackManager service. +func NewComposeStackManager(dataPath string) *ComposeStackManager { + return &ComposeStackManager{ + dataPath: dataPath, + } +} + +// Up will deploy a compose stack (equivalent of docker-compose up) +func (manager *ComposeStackManager) Up(stack *portainer.Stack, endpoint *portainer.Endpoint) error { + clientFactory, err := client.NewDefaultFactory(client.Options{ + TLS: endpoint.TLSConfig.TLS, + TLSVerify: endpoint.TLSConfig.TLSSkipVerify, + Host: endpoint.URL, + TLSCAFile: endpoint.TLSCACertPath, + TLSCertFile: endpoint.TLSCertPath, + TLSKeyFile: endpoint.TLSKeyPath, + APIVersion: "1.24", + }) + if err != nil { + return err + } + + composeFilePath := path.Join(stack.ProjectPath, stack.EntryPoint) + proj, err := docker.NewProject(&ctx.Context{ + ConfigDir: manager.dataPath, + Context: project.Context{ + ComposeFiles: []string{composeFilePath}, + EnvironmentLookup: &lookup.EnvfileLookup{ + Path: filepath.Join(stack.ProjectPath, ".env"), + }, + ProjectName: stack.Name, + }, + ClientFactory: clientFactory, + }, nil) + if err != nil { + return err + } + + return proj.Up(context.Background(), options.Up{}) +} + +// Down will shutdown a compose stack (equivalent of docker-compose down) +func (manager *ComposeStackManager) Down(stack *portainer.Stack, endpoint *portainer.Endpoint) error { + clientFactory, err := client.NewDefaultFactory(client.Options{ + TLS: endpoint.TLSConfig.TLS, + TLSVerify: endpoint.TLSConfig.TLSSkipVerify, + Host: endpoint.URL, + TLSCAFile: endpoint.TLSCACertPath, + TLSCertFile: endpoint.TLSCertPath, + TLSKeyFile: endpoint.TLSKeyPath, + APIVersion: "1.24", + }) + if err != nil { + return err + } + + composeFilePath := path.Join(stack.ProjectPath, stack.EntryPoint) + proj, err := docker.NewProject(&ctx.Context{ + Context: project.Context{ + ComposeFiles: []string{composeFilePath}, + ProjectName: stack.Name, + }, + ClientFactory: clientFactory, + }, nil) + if err != nil { + return err + } + + return proj.Down(context.Background(), options.Down{RemoveVolume: true, RemoveOrphans: true}) +} diff --git a/api/portainer.go b/api/portainer.go index e064285fa..5053cfccd 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -1,7 +1,5 @@ package portainer -import "io" - type ( // Pair defines a key/value string pair Pair struct { @@ -133,14 +131,19 @@ type ( // StackID represents a stack identifier (it must be composed of Name + "_" + SwarmID to create a unique identifier). StackID string + // StackType represents the type of the stack (compose v2, stack deploy v3). + StackType int + // Stack represents a Docker stack created via docker stack deploy. Stack struct { - ID StackID `json:"Id"` - Name string `json:"Name"` - EntryPoint string `json:"EntryPoint"` - SwarmID string `json:"SwarmId"` + ID StackID `json:"Id"` + Name string `json:"Name"` + Type StackType `json:"Type"` + EndpointID EndpointID `json:"EndpointId"` + SwarmID string `json:"SwarmId"` + EntryPoint string `json:"EntryPoint"` + Env []Pair `json:"Env"` ProjectPath string - Env []Pair `json:"Env"` } // RegistryID represents a registry identifier. @@ -352,8 +355,8 @@ type ( // StackService represents a service for managing stack data. StackService interface { Stack(ID StackID) (*Stack, error) + StackByName(name string) (*Stack, error) Stacks() ([]Stack, error) - StacksBySwarmID(ID string) ([]Stack, error) CreateStack(stack *Stack) error UpdateStack(ID StackID, stack *Stack) error DeleteStack(ID StackID) error @@ -412,13 +415,12 @@ type ( FileService interface { GetFileContent(filePath string) (string, error) RemoveDirectory(directoryPath string) error - StoreTLSFile(folder string, fileType TLSFileType, r io.Reader) error + StoreTLSFileFromBytes(folder string, fileType TLSFileType, data []byte) (string, error) GetPathForTLSFile(folder string, fileType TLSFileType) (string, error) DeleteTLSFile(folder string, fileType TLSFileType) error DeleteTLSFiles(folder string) error GetStackProjectPath(stackIdentifier string) string - StoreStackFileFromString(stackIdentifier, fileName, stackFileContent string) (string, error) - StoreStackFileFromReader(stackIdentifier, fileName string, r io.Reader) (string, error) + StoreStackFileFromBytes(stackIdentifier, fileName string, data []byte) (string, error) KeyPairFilesExist() (bool, error) StoreKeyPair(private, public []byte, privatePEMHeader, publicPEMHeader string) error LoadKeyPair() ([]byte, []byte, error) @@ -442,13 +444,19 @@ type ( TestConnectivity(settings *LDAPSettings) error } - // StackManager represents a service to manage stacks. - StackManager interface { + // SwarmStackManager represents a service to manage Swarm stacks. + SwarmStackManager interface { Login(dockerhub *DockerHub, registries []Registry, endpoint *Endpoint) Logout(endpoint *Endpoint) error Deploy(stack *Stack, prune bool, endpoint *Endpoint) error Remove(stack *Stack, endpoint *Endpoint) error } + + // ComposeStackManager represents a service to manage Compose stacks. + ComposeStackManager interface { + Up(stack *Stack, endpoint *Endpoint) error + Down(stack *Stack, endpoint *Endpoint) error + } ) const ( @@ -543,3 +551,11 @@ const ( // AzureEnvironment represents an endpoint connected to an Azure environment AzureEnvironment ) + +const ( + _ StackType = iota + // DockerSwarmStack represents a stack managed via docker stack + DockerSwarmStack + // DockerComposeStack represents a stack managed via docker-compose + DockerComposeStack +) diff --git a/api/swagger.yaml b/api/swagger.yaml index 481a99930..13277649e 100644 --- a/api/swagger.yaml +++ b/api/swagger.yaml @@ -50,9 +50,7 @@ info: Instead, it acts as a reverse-proxy to the Docker HTTP API. This means that you can execute Docker requests **via** the Portainer HTTP API. - To do so, you can use the `/endpoints/{id}/docker` Portainer API endpoint (which is not documented below due to Swagger limitations). This - endpoint has a restricted access policy so you still need to be authenticated to be able to query this endpoint. Any query on this endpoint will be proxied to the - Docker API of the associated endpoint (requests and responses objects are the same as documented in the Docker API). + To do so, you can use the `/endpoints/{id}/docker` Portainer API endpoint (which is not documented below due to Swagger limitations). This endpoint has a restricted access policy so you still need to be authenticated to be able to query this endpoint. Any query on this endpoint will be proxied to the Docker API of the associated endpoint (requests and responses objects are the same as documented in the Docker API). **NOTE**: You can find more information on how to query the Docker API in the [Docker official documentation](https://docs.docker.com/engine/api/v1.30/) as well as in [this Portainer example](https://gist.github.com/deviantony/77026d402366b4b43fa5918d41bc42f8). @@ -69,6 +67,8 @@ tags: description: "Manage how Portainer connects to the DockerHub" - name: "endpoints" description: "Manage Docker environments" +- name: "endpoint_groups" + description: "Manage endpoint groups" - name: "registries" description: "Manage Docker registries" - name: "resource_controls" @@ -87,6 +87,8 @@ tags: description: "Manage team memberships" - name: "templates" description: "Manage App Templates" +- name: "stacks" + description: "Manage stacks" - name: "upload" description: "Upload files" - name: "websocket" @@ -154,7 +156,7 @@ paths: 200: description: "Success" schema: - $ref: "#/definitions/DockerHubInspectResponse" + $ref: "#/definitions/DockerHubSubset" 500: description: "Server error" schema: @@ -181,6 +183,8 @@ paths: responses: 200: description: "Success" + schema: + $ref: "#/definitions/DockerHub" 400: description: "Invalid request" schema: @@ -233,11 +237,15 @@ paths: type: "string" description: "Name that will be used to identify this endpoint (example: my-endpoint)" required: true + - name: "EndpointType" + in: "formData" + type: "integer" + description: "Environment type. Value must be one of: 1 (Docker environment), 2 (Agent environment) or 3 (Azure environment)" + required: true - name: "URL" in: "formData" type: "string" - description: "URL or IP address of a Docker host (example: docker.mydomain.tld:2375)" - required: true + description: "URL or IP address of a Docker host (example: docker.mydomain.tld:2375). Required if endpoint type is set to 1 or 2." - name: "PublicURL" in: "formData" type: "string" @@ -267,6 +275,18 @@ paths: in: "formData" type: "file" description: "TLS client key file" + - name: "AzureApplicationID" + in: "formData" + type: "string" + description: "Azure application ID. Required if endpoint type is set to 3" + - name: "AzureTenantID" + in: "formData" + type: "string" + description: "Azure tenant ID. Required if endpoint type is set to 3" + - name: "AzureAuthenticationKey" + in: "formData" + type: "string" + description: "Azure authentication key. Required if endpoint type is set to 3" responses: 200: description: "Success" @@ -397,7 +417,7 @@ paths: required: true type: "integer" responses: - 200: + 204: description: "Success" 400: description: "Invalid request" @@ -452,6 +472,8 @@ paths: responses: 200: description: "Success" + schema: + $ref: "#/definitions/Endpoint" 400: description: "Invalid request" schema: @@ -470,76 +492,53 @@ paths: description: "Server error" schema: $ref: "#/definitions/GenericError" - - /endpoints/{endpointId}/stacks: + /endpoint_groups: get: tags: - - "stacks" - summary: "List stacks" + - "endpoint_groups" + summary: "List endpoint groups" description: | - List all stacks based on the current user authorizations. - Will return all stacks if using an administrator account otherwise it - will only return the list of stacks the user have access to. + List all endpoint groups based on the current user authorizations. Will + return all endpoint groups if using an administrator account otherwise it will + only return authorized endpoint groups. **Access policy**: restricted - operationId: "StackList" + operationId: "EndpointGroupList" produces: - "application/json" - parameters: - - name: "endpointId" - in: "path" - description: "Endpoint identifier" - required: true - type: "integer" + parameters: [] responses: 200: description: "Success" schema: - $ref: "#/definitions/StackListResponse" - 403: - description: "Unauthorized" - schema: - $ref: "#/definitions/GenericError" - examples: - application/json: - err: "Access denied to resource" + $ref: "#/definitions/EndpointGroupListResponse" 500: description: "Server error" schema: $ref: "#/definitions/GenericError" post: tags: - - "stacks" - summary: "Deploy a new stack" + - "endpoint_groups" + summary: "Create a new endpoint" description: | - Deploy a new stack into a Docker environment specified via the endpoint identifier. - **Access policy**: restricted - operationId: "StackCreate" + Create a new endpoint group. + **Access policy**: administrator + operationId: "EndpointGroupCreate" consumes: - "application/json" produces: - "application/json" parameters: - - name: "endpointId" - in: "path" - description: "Endpoint identifier" - required: true - type: "integer" - - name: "method" - in: "query" - description: "Stack deployment method. Possible values: string or repository." - required: true - type: "string" - in: "body" name: "body" - description: "Stack details. Used when" + description: "Registry details" required: true schema: - $ref: "#/definitions/StackCreateRequest" + $ref: "#/definitions/EndpointGroupCreateRequest" responses: 200: description: "Success" schema: - $ref: "#/definitions/StackCreateResponse" + $ref: "#/definitions/EndpointGroup" 400: description: "Invalid request" schema: @@ -547,44 +546,32 @@ paths: examples: application/json: err: "Invalid request data format" - 404: - description: "Endpoint not found" - schema: - $ref: "#/definitions/GenericError" - examples: - application/json: - err: "Endpoint not found" 500: description: "Server error" schema: $ref: "#/definitions/GenericError" - /endpoints/{endpointId}/stacks/{id}: + /endpoint_groups/{id}: get: tags: - - "stacks" - summary: "Inspect a stack" + - "endpoint_groups" + summary: "Inspect an endpoint group" description: | - Retrieve details about a stack. - **Access policy**: restricted - operationId: "StackInspect" + Retrieve details abount an endpoint group. + **Access policy**: administrator + operationId: "EndpointGroupInspect" produces: - "application/json" parameters: - - name: "endpointId" - in: "path" - description: "Endpoint identifier" - required: true - type: "integer" - name: "id" in: "path" - description: "Stack identifier" + description: "Endpoint group identifier" required: true - type: "string" + type: "integer" responses: 200: description: "Success" schema: - $ref: "#/definitions/Stack" + $ref: "#/definitions/EndpointGroup" 400: description: "Invalid request" schema: @@ -592,53 +579,46 @@ paths: examples: application/json: err: "Invalid request" - 403: - description: "Unauthorized" - schema: - $ref: "#/definitions/GenericError" 404: - description: "Stack not found" + description: "EndpointGroup not found" schema: $ref: "#/definitions/GenericError" examples: application/json: - err: "Stack not found" + err: "EndpointGroup not found" 500: description: "Server error" schema: $ref: "#/definitions/GenericError" put: tags: - - "stacks" - summary: "Update a stack" + - "endpoint_groups" + summary: "Update an endpoint group" description: | - Update a stack. - **Access policy**: restricted - operationId: "StackUpdate" + Update an endpoint group. + **Access policy**: administrator + operationId: "EndpointGroupUpdate" consumes: - "application/json" produces: - "application/json" parameters: - - name: "endpointId" - in: "path" - description: "Endpoint identifier" - required: true - type: "integer" - name: "id" in: "path" - description: "Stack identifier" + description: "EndpointGroup identifier" required: true - type: "string" + type: "integer" - in: "body" name: "body" - description: "Stack details" + description: "EndpointGroup details" required: true schema: - $ref: "#/definitions/StackUpdateRequest" + $ref: "#/definitions/EndpointGroupUpdateRequest" responses: 200: description: "Success" + schema: + $ref: "#/definitions/EndpointGroup" 400: description: "Invalid request" schema: @@ -647,37 +627,39 @@ paths: application/json: err: "Invalid request data format" 404: - description: "Stack not found" + description: "EndpointGroup not found" schema: $ref: "#/definitions/GenericError" examples: application/json: - err: "Stack not found" + err: "EndpointGroup not found" 500: description: "Server error" schema: $ref: "#/definitions/GenericError" + 503: + description: "EndpointGroup management disabled" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "EndpointGroup management is disabled" delete: tags: - - "stacks" - summary: "Remove a stack" + - "endpoint_groups" + summary: "Remove an endpoint group" description: | - Remove a stack. - **Access policy**: restricted - operationId: "StackDelete" + Remove an endpoint group. + **Access policy**: administrator + operationId: "EndpointGroupDelete" parameters: - - name: "endpointId" - in: "path" - description: "Endpoint identifier" - required: true - type: "integer" - name: "id" in: "path" - description: "Stack identifier" + description: "EndpointGroup identifier" required: true - type: "string" + type: "integer" responses: - 200: + 204: description: "Success" 400: description: "Invalid request" @@ -686,66 +668,68 @@ paths: examples: application/json: err: "Invalid request" - 403: - description: "Unauthorized" - schema: - $ref: "#/definitions/GenericError" 404: - description: "Stack not found" + description: "EndpointGroup not found" schema: $ref: "#/definitions/GenericError" examples: application/json: - err: "Stack not found" + err: "EndpointGroup not found" 500: description: "Server error" schema: $ref: "#/definitions/GenericError" - /endpoints/{endpointId}/stacks/{id}/stackfile: - get: + 503: + description: "EndpointGroup management disabled" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "EndpointGroup management is disabled" + /endpoint_groups/{id}/access: + put: tags: - - "stacks" - summary: "Retrieve the content of the Stack file for the specified stack" + - "endpoint_groups" + summary: "Manage accesses to an endpoint group" description: | - Get Stack file content. - **Access policy**: restricted - operationId: "StackFileInspect" + Manage user and team accesses to an endpoint group. + **Access policy**: administrator + operationId: "EndpointGroupAccessUpdate" + consumes: + - "application/json" produces: - "application/json" parameters: - - name: "endpointId" - in: "path" - description: "Endpoint identifier" - required: true - type: "integer" - name: "id" in: "path" - description: "Stack identifier" + description: "EndpointGroup identifier" required: true - type: "string" + type: "integer" + - in: "body" + name: "body" + description: "Authorizations details" + required: true + schema: + $ref: "#/definitions/EndpointGroupAccessUpdateRequest" responses: 200: description: "Success" schema: - $ref: "#/definitions/StackFileInspectResponse" + $ref: "#/definitions/EndpointGroup" 400: description: "Invalid request" schema: $ref: "#/definitions/GenericError" examples: application/json: - err: "Invalid request" - 403: - description: "Unauthorized" - schema: - $ref: "#/definitions/GenericError" + err: "Invalid request data format" 404: - description: "Stack not found" + description: "EndpointGroup not found" schema: $ref: "#/definitions/GenericError" examples: application/json: - err: "Stack not found" + err: "EndpointGroup not found" 500: description: "Server error" schema: @@ -796,7 +780,7 @@ paths: 200: description: "Success" schema: - $ref: "#/definitions/RegistryCreateResponse" + $ref: "#/definitions/Registry" 400: description: "Invalid request" schema: @@ -882,6 +866,8 @@ paths: responses: 200: description: "Success" + schema: + $ref: "#/definitions/Registry" 400: description: "Invalid request" schema: @@ -922,7 +908,7 @@ paths: required: true type: "integer" responses: - 200: + 204: description: "Success" 400: description: "Invalid request" @@ -970,6 +956,8 @@ paths: responses: 200: description: "Success" + schema: + $ref: "#/definitions/Registry" 400: description: "Invalid request" schema: @@ -1011,6 +999,8 @@ paths: responses: 200: description: "Success" + schema: + $ref: "#/definitions/ResourceControl" 400: description: "Invalid request" schema: @@ -1064,6 +1054,8 @@ paths: responses: 200: description: "Success" + schema: + $ref: "#/definitions/ResourceControl" 400: description: "Invalid request" schema: @@ -1104,7 +1096,7 @@ paths: required: true type: "integer" responses: - 200: + 204: description: "Success" 400: description: "Invalid request" @@ -1174,6 +1166,8 @@ paths: responses: 200: description: "Success" + schema: + $ref: "#/definitions/Settings" 400: description: "Invalid request" schema: @@ -1227,7 +1221,7 @@ paths: schema: $ref: "#/definitions/SettingsLDAPCheckRequest" responses: - 200: + 204: description: "Success" 400: description: "Invalid request" @@ -1261,6 +1255,305 @@ paths: description: "Server error" schema: $ref: "#/definitions/GenericError" + /stacks: + get: + tags: + - "stacks" + summary: "List stacks" + description: | + List all stacks based on the current user authorizations. + Will return all stacks if using an administrator account otherwise it + will only return the list of stacks the user have access to. + **Access policy**: restricted + operationId: "StackList" + produces: + - "application/json" + parameters: + - name: "filters" + in: "query" + description: | + Filters to process on the stack list. Encoded as JSON (a map[string]string). + For example, {"SwarmID": "jpofkc0i9uo9wtx1zesuk649w"} will only return stacks that are part + of the specified Swarm cluster. Available filters: EndpointID, SwarmID. + type: "string" + responses: + 200: + description: "Success" + schema: + $ref: "#/definitions/StackListResponse" + examples: + application/json: + err: "Access denied to resource" + 500: + description: "Server error" + schema: + $ref: "#/definitions/GenericError" + post: + tags: + - "stacks" + summary: "Deploy a new stack" + description: | + Deploy a new stack into a Docker environment specified via the endpoint identifier. + **Access policy**: restricted + operationId: "StackCreate" + consumes: + - "application/json" + produces: + - "application/json" + parameters: + - name: "type" + in: "query" + description: "Stack deployment type. Possible values: 1 (Swarm stack) or 2 (Compose stack)." + required: true + type: "integer" + - name: "method" + in: "query" + description: "Stack deployment method. Possible values: file, string or repository." + required: true + type: "string" + - name: "endpointId" + in: "query" + description: "Identifier of the endpoint that will be used to deploy the stack." + required: true + type: "integer" + - in: "body" + name: "body" + description: "Stack details. Required when method equals string or repository." + schema: + $ref: "#/definitions/StackCreateRequest" + - name: "Name" + in: "formData" + type: "string" + description: "Name of the stack. Required when method equals file." + - name: "EndpointID" + in: "formData" + type: "string" + description: "Endpoint identifier used to deploy the stack. Required when method equals file." + - name: "SwarmID" + in: "formData" + type: "string" + description: "Swarm cluster identifier. Required when method equals file and type equals 1." + - name: "StackFileContent" + in: "formData" + type: "file" + description: "Stack file. Required when method equals file." + - name: "Env" + in: "formData" + type: "string" + description: "Environment variables passed during deployment, represented as a JSON array [{'name': 'name', 'value': 'value'}]. Optional, used when method equals file and type equals 1." + responses: + 200: + description: "Success" + schema: + $ref: "#/definitions/Stack" + 400: + description: "Invalid request" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Invalid request data format" + 403: + description: "Unauthorized" + schema: + $ref: "#/definitions/GenericError" + 404: + description: "Endpoint not found" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Endpoint not found" + 500: + description: "Server error" + schema: + $ref: "#/definitions/GenericError" + /stacks/{id}: + get: + tags: + - "stacks" + summary: "Inspect a stack" + description: | + Retrieve details about a stack. + **Access policy**: restricted + operationId: "StackInspect" + produces: + - "application/json" + parameters: + - name: "id" + in: "path" + description: "Stack identifier" + required: true + type: "string" + responses: + 200: + description: "Success" + schema: + $ref: "#/definitions/Stack" + 400: + description: "Invalid request" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Invalid request" + 403: + description: "Unauthorized" + schema: + $ref: "#/definitions/GenericError" + 404: + description: "Stack not found" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Stack not found" + 500: + description: "Server error" + schema: + $ref: "#/definitions/GenericError" + put: + tags: + - "stacks" + summary: "Update a stack" + description: | + Update a stack. + **Access policy**: restricted + operationId: "StackUpdate" + consumes: + - "application/json" + produces: + - "application/json" + parameters: + - name: "id" + in: "path" + description: "Stack identifier" + required: true + type: "string" + - in: "body" + name: "body" + description: "Stack details" + required: true + schema: + $ref: "#/definitions/StackUpdateRequest" + responses: + 200: + description: "Success" + schema: + $ref: "#/definitions/Stack" + 400: + description: "Invalid request" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Invalid request data format" + 403: + description: "Unauthorized" + schema: + $ref: "#/definitions/GenericError" + 404: + description: "Stack not found" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Stack not found" + 500: + description: "Server error" + schema: + $ref: "#/definitions/GenericError" + delete: + tags: + - "stacks" + summary: "Remove a stack" + description: | + Remove a stack. + **Access policy**: restricted + operationId: "StackDelete" + parameters: + - name: "id" + in: "path" + description: "Stack identifier" + required: true + type: "string" + - name: "external" + in: "query" + description: "Set to true to delete an external stack. Only external Swarm stacks are supported." + type: "boolean" + - name: "endpointId" + in: "query" + description: "Endpoint identifier used to remove an external stack (required when external is set to true)" + type: "string" + responses: + 204: + description: "Success" + 400: + description: "Invalid request" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Invalid request" + 403: + description: "Unauthorized" + schema: + $ref: "#/definitions/GenericError" + 404: + description: "Stack not found" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Stack not found" + 500: + description: "Server error" + schema: + $ref: "#/definitions/GenericError" + /stacks/{id}/file: + get: + tags: + - "stacks" + summary: "Retrieve the content of the Stack file for the specified stack" + description: | + Get Stack file content. + **Access policy**: restricted + operationId: "StackFileInspect" + produces: + - "application/json" + parameters: + - name: "id" + in: "path" + description: "Stack identifier" + required: true + type: "string" + responses: + 200: + description: "Success" + schema: + $ref: "#/definitions/StackFileInspectResponse" + 400: + description: "Invalid request" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Invalid request" + 403: + description: "Unauthorized" + schema: + $ref: "#/definitions/GenericError" + 404: + description: "Stack not found" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Stack not found" + 500: + description: "Server error" + schema: + $ref: "#/definitions/GenericError" /users: get: tags: @@ -1306,7 +1599,7 @@ paths: 200: description: "Success" schema: - $ref: "#/definitions/UserCreateResponse" + $ref: "#/definitions/UserSubset" 400: description: "Invalid request" schema: @@ -1399,6 +1692,8 @@ paths: responses: 200: description: "Success" + schema: + $ref: "#/definitions/User" 400: description: "Invalid request" schema: @@ -1439,7 +1734,7 @@ paths: required: true type: "integer" responses: - 200: + 204: description: "Success" 400: description: "Invalid request" @@ -1562,10 +1857,8 @@ paths: - "application/json" parameters: [] responses: - 200: + 204: description: "Success" - schema: - $ref: "#/definitions/UserListResponse" 404: description: "User not found" schema: @@ -1601,6 +1894,8 @@ paths: responses: 200: description: "Success" + schema: + $ref: "#/definitions/User" 400: description: "Invalid request" schema: @@ -1707,7 +2002,7 @@ paths: 200: description: "Success" schema: - $ref: "#/definitions/TeamCreateResponse" + $ref: "#/definitions/Team" 400: description: "Invalid request" schema: @@ -1840,7 +2135,7 @@ paths: required: true type: "integer" responses: - 200: + 204: description: "Success" 400: description: "Invalid request" @@ -1953,7 +2248,7 @@ paths: 200: description: "Success" schema: - $ref: "#/definitions/TeamMembershipCreateResponse" + $ref: "#/definitions/TeamMembership" 400: description: "Invalid request" schema: @@ -2007,6 +2302,8 @@ paths: responses: 200: description: "Success" + schema: + $ref: "#/definitions/TeamMembership" 400: description: "Invalid request" schema: @@ -2047,7 +2344,7 @@ paths: required: true type: "integer" responses: - 200: + 204: description: "Success" 400: description: "Invalid request" @@ -2144,6 +2441,21 @@ definitions: type: "integer" example: 1 description: "Team role (1 for team leader and 2 for team member)" + UserSubset: + type: "object" + properties: + Id: + type: "integer" + example: 1 + description: "User identifier" + Username: + type: "string" + example: "bob" + description: "Username" + Role: + type: "integer" + example: 1 + description: "User role (1 for administrator account and 2 for regular account)" User: type: "object" properties: @@ -2155,6 +2467,10 @@ definitions: type: "string" example: "bob" description: "Username" + Password: + type: "string" + example: "passwd" + description: "Password" Role: type: "integer" example: 1 @@ -2227,7 +2543,21 @@ definitions: type: "string" example: "/data/tls/key.pem" description: "Path to the TLS client key file" - + AzureCredentials: + type: "object" + properties: + ApplicationID: + type: "string" + example: "eag7cdo9-o09l-9i83-9dO9-f0b23oe78db4" + description: "Azure application ID" + TenantID: + type: "string" + example: "34ddc78d-4fel-2358-8cc1-df84c8o839f5" + description: "Azure tenant ID" + AuthenticationKey: + type: "string" + example: "cOrXoK/1D35w8YQ8nH1/8ZGwzz45JIYD5jxHKXEQknk=" + description: "Azure authentication key" LDAPSearchSettings: type: "object" properties: @@ -2317,6 +2647,14 @@ definitions: value: type: "string" example: "bar" + Pair: + properties: + name: + type: "string" + example: "name" + value: + type: "string" + example: "value" Registry: type: "object" properties: @@ -2358,6 +2696,76 @@ definitions: type: "integer" example: 1 description: "Team identifier" + RegistrySubset: + type: "object" + properties: + Id: + type: "integer" + example: 1 + description: "Registry identifier" + Name: + type: "string" + example: "my-registry" + description: "Registry name" + URL: + type: "string" + example: "registry.mydomain.tld:2375" + description: "URL or IP address of the Docker registry" + Authentication: + type: "boolean" + example: true + description: "Is authentication against this registry enabled" + Username: + type: "string" + example: "registry_user" + description: "Username used to authenticate against this registry" + AuthorizedUsers: + type: "array" + description: "List of user identifiers authorized to use this registry" + items: + type: "integer" + example: 1 + description: "User identifier" + AuthorizedTeams: + type: "array" + description: "List of team identifiers authorized to use this registry" + items: + type: "integer" + example: 1 + description: "Team identifier" + EndpointGroup: + type: "object" + properties: + Id: + type: "integer" + example: 1 + description: "Endpoint group identifier" + Name: + type: "string" + example: "my-endpoint-group" + description: "Endpoint group name" + Description: + type: "string" + example: "Description associated to the endpoint group" + description: "Endpoint group description" + AuthorizedUsers: + type: "array" + description: "List of user identifiers authorized to connect to this endpoint group. Will be inherited by endpoints that are part of the group" + items: + type: "integer" + example: 1 + description: "User identifier" + AuthorizedTeams: + type: "array" + description: "List of team identifiers authorized to connect to this endpoint. Will be inherited by endpoints that are part of the group" + items: + type: "integer" + example: 1 + description: "Team identifier" + Labels: + type: "array" + items: + $ref: "#/definitions/Pair" Endpoint: type: "object" properties: @@ -2372,7 +2780,7 @@ definitions: Type: type: "integer" example: 1 - description: "Endpoint environment type. 1 for a Docker environment, 2 for an agent on Docker environment." + description: "Endpoint environment type. 1 for a Docker environment, 2 for an agent on Docker environment or 3 for an Azure environment." URL: type: "string" example: "docker.mydomain.tld:2375" @@ -2399,6 +2807,53 @@ definitions: type: "integer" example: 1 description: "Team identifier" + TLSConfig: + $ref: "#/definitions/TLSConfiguration" + AzureCredentials: + $ref: "#/definitions/AzureCredentials" + EndpointSubset: + type: "object" + properties: + Id: + type: "integer" + example: 1 + description: "Endpoint identifier" + Name: + type: "string" + example: "my-endpoint" + description: "Endpoint name" + Type: + type: "integer" + example: 1 + description: "Endpoint environment type. 1 for a Docker environment, 2 for an agent on Docker environment, 3 for an Azure environment." + URL: + type: "string" + example: "docker.mydomain.tld:2375" + description: "URL or IP address of the Docker host associated to this endpoint" + PublicURL: + type: "string" + example: "docker.mydomain.tld:2375" + description: "URL or IP address where exposed containers will be reachable" + GroupID: + type: "integer" + example: 1 + description: "Endpoint group identifier" + AuthorizedUsers: + type: "array" + description: "List of user identifiers authorized to connect to this endpoint" + items: + type: "integer" + example: 1 + description: "User identifier" + AuthorizedTeams: + type: "array" + description: "List of team identifiers authorized to connect to this endpoint" + items: + type: "integer" + example: 1 + description: "Team identifier" + TLSConfig: + $ref: "#/definitions/TLSConfiguration" GenericError: type: "object" properties: @@ -2427,7 +2882,18 @@ definitions: type: "string" example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwidXNlcm5hbWUiOiJhZG1pbiIsInJvbGUiOjEsImV4cCI6MTQ5OTM3NjE1NH0.NJ6vE8FY1WG6jsRQzfMqeatJ4vh2TWAeeYfDhP71YEE" description: "JWT token used to authenticate against the API" - DockerHubInspectResponse: + DockerHubSubset: + type: "object" + properties: + Authentication: + type: "boolean" + example: true + description: "Is authentication against DockerHub enabled" + Username: + type: "string" + example: "hub_user" + description: "Username used to authenticate against the DockerHub" + DockerHub: type: "object" properties: Authentication: @@ -2442,6 +2908,45 @@ definitions: type: "string" example: "hub_password" description: "Password used to authenticate against the DockerHub" + ResourceControl: + type: "object" + properties: + ResourceID: + type: "string" + example: "617c5f22bb9b023d6daab7cba43a57576f83492867bc767d1c59416b065e5f08" + description: "Docker resource identifier on which access control will be applied.\ + \ In the case of a resource control applied to a stack, use the stack name as identifier" + Type: + type: "string" + example: "container" + description: "Type of Docker resource. Valid values are: container, volume\ + \ service, secret, config or stack" + AdministratorsOnly: + type: "boolean" + example: true + description: "Restrict access to the associated resource to administrators\ + \ only" + Users: + type: "array" + description: "List of user identifiers with access to the associated resource" + items: + type: "integer" + example: 1 + description: "User identifier" + Teams: + type: "array" + description: "List of team identifiers with access to the associated resource" + items: + type: "integer" + example: 1 + description: "Team identifier" + SubResourceIDs: + type: "array" + description: "List of Docker resources that will inherit this access control" + items: + type: "string" + example: "617c5f22bb9b023d6daab7cba43a57576f83492867bc767d1c59416b065e5f08" + description: "Docker resource identifier" DockerHubUpdateRequest: type: "object" required: @@ -2464,7 +2969,11 @@ definitions: EndpointListResponse: type: "array" items: - $ref: "#/definitions/Endpoint" + $ref: "#/definitions/EndpointSubset" + EndpointGroupListResponse: + type: "array" + items: + $ref: "#/definitions/EndpointGroup" EndpointUpdateRequest: type: "object" properties: @@ -2481,6 +2990,10 @@ definitions: example: "docker.mydomain.tld:2375" description: "URL or IP address where exposed containers will be reachable.\ \ Defaults to URL if not specified" + GroupID: + type: "integer" + example: "1" + description: "Group identifier" TLS: type: "boolean" example: true @@ -2493,6 +3006,18 @@ definitions: type: "boolean" example: false description: "Skip client verification when using TLS" + ApplicationID: + type: "string" + example: "eag7cdo9-o09l-9i83-9dO9-f0b23oe78db4" + description: "Azure application ID" + TenantID: + type: "string" + example: "34ddc78d-4fel-2358-8cc1-df84c8o839f5" + description: "Azure tenant ID" + AuthenticationKey: + type: "string" + example: "cOrXoK/1D35w8YQ8nH1/8ZGwzz45JIYD5jxHKXEQknk=" + description: "Azure authentication key" EndpointAccessUpdateRequest: type: "object" properties: @@ -2510,6 +3035,23 @@ definitions: type: "integer" example: 1 description: "Team identifier" + EndpointGroupAccessUpdateRequest: + type: "object" + properties: + AuthorizedUsers: + type: "array" + description: "List of user identifiers authorized to connect to this endpoint" + items: + type: "integer" + example: 1 + description: "User identifier" + AuthorizedTeams: + type: "array" + description: "List of team identifiers authorized to connect to this endpoint" + items: + type: "integer" + example: 1 + description: "Team identifier" RegistryCreateRequest: type: "object" required: @@ -2539,17 +3081,10 @@ definitions: type: "string" example: "registry_password" description: "Password used to authenticate against this registry" - RegistryCreateResponse: - type: "object" - properties: - Id: - type: "integer" - example: 1 - description: "Id of the registry" RegistryListResponse: type: "array" items: - $ref: "#/definitions/Registry" + $ref: "#/definitions/RegistrySubset" RegistryUpdateRequest: type: "object" required: @@ -2699,6 +3234,52 @@ definitions: type: "boolean" example: true description: "Whether non-administrator users should be able to use privileged mode when creating containers" + EndpointGroupCreateRequest: + type: "object" + required: + - "Name" + properties: + Name: + type: "string" + example: "my-endpoint-group" + description: "Endpoint group name" + Description: + type: "string" + example: "Endpoint group description" + description: "Endpoint group description" + Labels: + type: "array" + items: + $ref: "#/definitions/Pair" + AssociatedEndpoints: + type: "array" + description: "List of endpoint identifiers that will be part of this group" + items: + type: "integer" + example: 1 + description: "Endpoint identifier" + EndpointGroupUpdateRequest: + type: "object" + properties: + Name: + type: "string" + example: "my-endpoint-group" + description: "Endpoint group name" + Description: + type: "string" + example: "Endpoint group description" + description: "Endpoint group description" + Labels: + type: "array" + items: + $ref: "#/definitions/Pair" + AssociatedEndpoints: + type: "array" + description: "List of endpoint identifiers that will be part of this group" + items: + type: "integer" + example: 1 + description: "Endpoint identifier" UserCreateRequest: type: "object" required: @@ -2718,17 +3299,10 @@ definitions: type: "integer" example: 1 description: "User role (1 for administrator account and 2 for regular account)" - UserCreateResponse: - type: "object" - properties: - Id: - type: "integer" - example: 1 - description: "Id of the user" UserListResponse: type: "array" items: - $ref: "#/definitions/User" + $ref: "#/definitions/UserSubset" UserUpdateRequest: type: "object" properties: @@ -2769,13 +3343,6 @@ definitions: type: "string" example: "developers" description: "Name" - TeamCreateResponse: - type: "object" - properties: - Id: - type: "integer" - example: 1 - description: "Id of the team" TeamListResponse: type: "array" items: @@ -2813,13 +3380,6 @@ definitions: type: "integer" example: 1 description: "Role for the user inside the team (1 for leader and 2 for regular member)" - TeamMembershipCreateResponse: - type: "object" - properties: - Id: - type: "integer" - example: 1 - description: "Id of the team membership" TeamMembershipListResponse: type: "array" items: @@ -2886,7 +3446,6 @@ definitions: type: "object" required: - "Name" - - "SwarmID" properties: Name: type: "string" @@ -2895,7 +3454,7 @@ definitions: SwarmID: type: "string" example: "jpofkc0i9uo9wtx1zesuk649w" - description: "Cluster identifier of the Swarm cluster" + description: "Swarm cluster identifier. Required when creating a Swarm stack (type 1)." StackFileContent: type: "string" example: "version: 3\n services:\n web:\n image:nginx" @@ -2907,7 +3466,7 @@ definitions: ComposeFilePathInRepository: type: "string" example: "docker-compose.yml" - description: "Path to the Stack file inside the Git repository. Required when using the 'repository' deployment method." + description: "Path to the Stack file inside the Git repository. Will default to 'docker-compose.yml' if not specified." RepositoryAuthentication: type: "boolean" example: true @@ -2933,13 +3492,6 @@ definitions: value: type: "string" example: "password" - StackCreateResponse: - type: "object" - properties: - Id: - type: "string" - example: "myStack_jpofkc0i9uo9wtx1zesuk649w" - description: "Id of the stack" StackListResponse: type: "array" items: @@ -2955,6 +3507,14 @@ definitions: type: "string" example: "myStack" description: "Stack name" + Type: + type: "integer" + example: "1" + description: "Stack type. 1 for a Swarm stack, 2 for a Compose stack" + EndpointID: + type: "integer" + example: "1" + description: "Endpoint identifier. Reference the endpoint that will be used for deployment " EntryPoint: type: "string" example: "docker-compose.yml" diff --git a/app/constants.js b/app/constants.js index 8a16181f7..a51ce323e 100644 --- a/app/constants.js +++ b/app/constants.js @@ -6,6 +6,7 @@ angular.module('portainer') .constant('API_ENDPOINT_REGISTRIES', 'api/registries') .constant('API_ENDPOINT_RESOURCE_CONTROLS', 'api/resource_controls') .constant('API_ENDPOINT_SETTINGS', 'api/settings') +.constant('API_ENDPOINT_STACKS', 'api/stacks') .constant('API_ENDPOINT_STATUS', 'api/status') .constant('API_ENDPOINT_USERS', 'api/users') .constant('API_ENDPOINT_TEAMS', 'api/teams') diff --git a/app/docker/__module.js b/app/docker/__module.js index 28ad5e52d..82041efeb 100644 --- a/app/docker/__module.js +++ b/app/docker/__module.js @@ -49,9 +49,6 @@ angular.module('portainer.docker', ['portainer.app']) templateUrl: 'app/docker/views/containers/containers.html', controller: 'ContainersController' } - }, - params: { - selectedContainers: [] } }; @@ -314,39 +311,6 @@ angular.module('portainer.docker', ['portainer.app']) } }; - var stacks = { - name: 'docker.stacks', - url: '/stacks', - views: { - 'content@': { - templateUrl: 'app/docker/views/stacks/stacks.html', - controller: 'StacksController' - } - } - }; - - var stack = { - name: 'docker.stacks.stack', - url: '/:id', - views: { - 'content@': { - templateUrl: 'app/docker/views/stacks/edit/stack.html', - controller: 'StackController' - } - } - }; - - var stackCreation = { - name: 'docker.stacks.new', - url: '/new', - views: { - 'content@': { - templateUrl: 'app/docker/views/stacks/create/createstack.html', - controller: 'CreateStackController' - } - } - }; - var swarm = { name: 'docker.swarm', url: '/swarm', @@ -489,9 +453,6 @@ angular.module('portainer.docker', ['portainer.app']) $stateRegistryProvider.register(service); $stateRegistryProvider.register(serviceCreation); $stateRegistryProvider.register(serviceLogs); - $stateRegistryProvider.register(stacks); - $stateRegistryProvider.register(stack); - $stateRegistryProvider.register(stackCreation); $stateRegistryProvider.register(swarm); $stateRegistryProvider.register(swarmVisualizer); $stateRegistryProvider.register(tasks); diff --git a/app/docker/components/datatables/containers-datatable/actions/containersDatatableActions.html b/app/docker/components/datatables/containers-datatable/actions/containersDatatableActions.html new file mode 100644 index 000000000..37bc895ed --- /dev/null +++ b/app/docker/components/datatables/containers-datatable/actions/containersDatatableActions.html @@ -0,0 +1,35 @@ +
+
+ + + + + + + +
+ +
diff --git a/app/docker/components/datatables/containers-datatable/actions/containersDatatableActions.js b/app/docker/components/datatables/containers-datatable/actions/containersDatatableActions.js new file mode 100644 index 000000000..996b5e2c6 --- /dev/null +++ b/app/docker/components/datatables/containers-datatable/actions/containersDatatableActions.js @@ -0,0 +1,12 @@ +angular.module('portainer.docker').component('containersDatatableActions', { + templateUrl: 'app/docker/components/datatables/containers-datatable/actions/containersDatatableActions.html', + controller: 'ContainersDatatableActionsController', + bindings: { + selectedItems: '=', + selectedItemCount: '=', + noStoppedItemsSelected: '=', + noRunningItemsSelected: '=', + noPausedItemsSelected: '=', + showAddAction: '<' + } +}); diff --git a/app/docker/components/datatables/containers-datatable/actions/containersDatatableActionsController.js b/app/docker/components/datatables/containers-datatable/actions/containersDatatableActionsController.js new file mode 100644 index 000000000..94ce439b3 --- /dev/null +++ b/app/docker/components/datatables/containers-datatable/actions/containersDatatableActionsController.js @@ -0,0 +1,104 @@ +angular.module('portainer.docker') +.controller('ContainersDatatableActionsController', ['$state', 'ContainerService', 'ModalService', 'Notifications', 'HttpRequestHelper', +function ($state, ContainerService, ModalService, Notifications, HttpRequestHelper) { + this.startAction = function(selectedItems) { + var successMessage = 'Container successfully started'; + var errorMessage = 'Unable to start container'; + executeActionOnContainerList(selectedItems, ContainerService.startContainer, successMessage, errorMessage); + }; + + this.stopAction = function(selectedItems) { + var successMessage = 'Container successfully stopped'; + var errorMessage = 'Unable to stop container'; + executeActionOnContainerList(selectedItems, ContainerService.stopContainer, successMessage, errorMessage); + }; + + this.restartAction = function(selectedItems) { + var successMessage = 'Container successfully restarted'; + var errorMessage = 'Unable to restart container'; + executeActionOnContainerList(selectedItems, ContainerService.restartContainer, successMessage, errorMessage); + }; + + this.killAction = function(selectedItems) { + var successMessage = 'Container successfully killed'; + var errorMessage = 'Unable to kill container'; + executeActionOnContainerList(selectedItems, ContainerService.killContainer, successMessage, errorMessage); + }; + + this.pauseAction = function(selectedItems) { + var successMessage = 'Container successfully paused'; + var errorMessage = 'Unable to pause container'; + executeActionOnContainerList(selectedItems, ContainerService.pauseContainer, successMessage, errorMessage); + }; + + this.resumeAction = function(selectedItems) { + var successMessage = 'Container successfully resumed'; + var errorMessage = 'Unable to resume container'; + executeActionOnContainerList(selectedItems, ContainerService.resumeContainer, successMessage, errorMessage); + }; + + this.removeAction = function(selectedItems) { + var isOneContainerRunning = false; + for (var i = 0; i < selectedItems.length; i++) { + var container = selectedItems[i]; + if (container.State === 'running') { + isOneContainerRunning = true; + break; + } + } + + var title = 'You are about to remove one or more container.'; + if (isOneContainerRunning) { + title = 'You are about to remove one or more running container.'; + } + + ModalService.confirmContainerDeletion(title, function (result) { + if(!result) { return; } + var cleanVolumes = false; + if (result[0]) { + cleanVolumes = true; + } + removeSelectedContainers(selectedItems, cleanVolumes); + } + ); + }; + + function executeActionOnContainerList(containers, action, successMessage, errorMessage) { + var actionCount = containers.length; + angular.forEach(containers, function (container) { + HttpRequestHelper.setPortainerAgentTargetHeader(container.NodeName); + action(container.Id) + .then(function success() { + Notifications.success(successMessage, container.Names[0]); + }) + .catch(function error(err) { + Notifications.error('Failure', err, errorMessage); + }) + .finally(function final() { + --actionCount; + if (actionCount === 0) { + $state.reload(); + } + }); + }); + } + + function removeSelectedContainers(containers, cleanVolumes) { + var actionCount = containers.length; + angular.forEach(containers, function (container) { + ContainerService.remove(container, cleanVolumes) + .then(function success() { + Notifications.success('Container successfully removed', container.Names[0]); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to remove container'); + }) + .finally(function final() { + --actionCount; + if (actionCount === 0) { + $state.reload(); + } + }); + }); + } +}]); diff --git a/app/docker/components/datatables/containers-datatable/containersDatatable.html b/app/docker/components/datatables/containers-datatable/containersDatatable.html index e07e89dbd..df073801e 100644 --- a/app/docker/components/datatables/containers-datatable/containersDatatable.html +++ b/app/docker/components/datatables/containers-datatable/containersDatatable.html @@ -51,41 +51,14 @@
-
-
- - - - - - - -
- -
+
diff --git a/app/docker/components/dockerSidebarContent/dockerSidebarContent.html b/app/docker/components/dockerSidebarContent/dockerSidebarContent.html index ba0001c9c..59da699dc 100644 --- a/app/docker/components/dockerSidebarContent/dockerSidebarContent.html +++ b/app/docker/components/dockerSidebarContent/dockerSidebarContent.html @@ -7,8 +7,8 @@ LinuxServer.io - - + diff --git a/app/docker/views/templates/templatesController.js b/app/docker/views/templates/templatesController.js index 8e5ada599..7ca0dcaa2 100644 --- a/app/docker/views/templates/templatesController.js +++ b/app/docker/views/templates/templatesController.js @@ -1,6 +1,6 @@ angular.module('portainer.docker') -.controller('TemplatesController', ['$scope', '$q', '$state', '$transition$', '$anchorScroll', '$filter', 'ContainerService', 'ContainerHelper', 'ImageService', 'NetworkService', 'TemplateService', 'TemplateHelper', 'VolumeService', 'Notifications', 'PaginationService', 'ResourceControlService', 'Authentication', 'FormValidator', 'SettingsService', 'StackService', -function ($scope, $q, $state, $transition$, $anchorScroll, $filter, ContainerService, ContainerHelper, ImageService, NetworkService, TemplateService, TemplateHelper, VolumeService, Notifications, PaginationService, ResourceControlService, Authentication, FormValidator, SettingsService, StackService) { +.controller('TemplatesController', ['$scope', '$q', '$state', '$transition$', '$anchorScroll', '$filter', 'ContainerService', 'ContainerHelper', 'ImageService', 'NetworkService', 'TemplateService', 'TemplateHelper', 'VolumeService', 'Notifications', 'PaginationService', 'ResourceControlService', 'Authentication', 'FormValidator', 'SettingsService', 'StackService', 'EndpointProvider', +function ($scope, $q, $state, $transition$, $anchorScroll, $filter, ContainerService, ContainerHelper, ImageService, NetworkService, TemplateService, TemplateHelper, VolumeService, Notifications, PaginationService, ResourceControlService, Authentication, FormValidator, SettingsService, StackService, EndpointProvider) { $scope.state = { selectedTemplate: null, showAdvancedOptions: false, @@ -113,13 +113,14 @@ function ($scope, $q, $state, $transition$, $anchorScroll, $filter, ContainerSer ComposeFilePathInRepository: template.Repository.stackfile }; - StackService.createStackFromGitRepository(stackName, repositoryOptions, template.Env) + var endpointId = EndpointProvider.endpointID(); + StackService.createSwarmStackFromGitRepository(stackName, repositoryOptions, template.Env, endpointId) .then(function success(data) { return ResourceControlService.applyResourceControl('stack', stackName, userId, accessControlData, []); }) .then(function success() { Notifications.success('Stack successfully deployed'); - $state.go('docker.stacks'); + $state.go('portainer.stacks'); }) .catch(function error(err) { Notifications.warning('Deployment error', err.err.data.err); diff --git a/app/portainer/__module.js b/app/portainer/__module.js index 2b017bb2b..701a2bb8e 100644 --- a/app/portainer/__module.js +++ b/app/portainer/__module.js @@ -253,6 +253,39 @@ angular.module('portainer.app', []) } }; + var stacks = { + name: 'portainer.stacks', + url: '/stacks', + views: { + 'content@': { + templateUrl: 'app/portainer/views/stacks/stacks.html', + controller: 'StacksController' + } + } + }; + + var stack = { + name: 'portainer.stacks.stack', + url: '/:name?id&type&external', + views: { + 'content@': { + templateUrl: 'app/portainer/views/stacks/edit/stack.html', + controller: 'StackController' + } + } + }; + + var stackCreation = { + name: 'portainer.stacks.new', + url: '/new', + views: { + 'content@': { + templateUrl: 'app/portainer/views/stacks/create/createstack.html', + controller: 'CreateStackController' + } + } + }; + var support = { name: 'portainer.support', url: '/support', @@ -329,6 +362,9 @@ angular.module('portainer.app', []) $stateRegistryProvider.register(registryCreation); $stateRegistryProvider.register(settings); $stateRegistryProvider.register(settingsAuthentication); + $stateRegistryProvider.register(stacks); + $stateRegistryProvider.register(stack); + $stateRegistryProvider.register(stackCreation); $stateRegistryProvider.register(support); $stateRegistryProvider.register(users); $stateRegistryProvider.register(user); diff --git a/app/portainer/components/datatables/stacks-datatable/stacksDatatable.html b/app/portainer/components/datatables/stacks-datatable/stacksDatatable.html index 4d0039df2..bf6e27818 100644 --- a/app/portainer/components/datatables/stacks-datatable/stacksDatatable.html +++ b/app/portainer/components/datatables/stacks-datatable/stacksDatatable.html @@ -16,7 +16,7 @@ ng-disabled="$ctrl.state.selectedItemCount === 0" ng-click="$ctrl.removeAction($ctrl.state.selectedItems)"> Remove - @@ -39,6 +39,14 @@ + + - + + + - + - +
{{ item.IP ? item.IP : '-' }} {{ item.NodeName ? item.NodeName : '-' }} - + {{ p.public }}:{{ p.private }} - diff --git a/app/docker/components/datatables/containers-datatable/containersDatatable.js b/app/docker/components/datatables/containers-datatable/containersDatatable.js index 8d04ef011..5fc7584ce 100644 --- a/app/docker/components/datatables/containers-datatable/containersDatatable.js +++ b/app/docker/components/datatables/containers-datatable/containersDatatable.js @@ -11,14 +11,6 @@ angular.module('portainer.docker').component('containersDatatable', { showTextFilter: '<', showOwnershipColumn: '<', showHostColumn: '<', - publicUrl: '<', - containerNameTruncateSize: '<', - startAction: '<', - stopAction: '<', - killAction: '<', - restartAction: '<', - pauseAction: '<', - resumeAction: '<', - removeAction: '<' + showAddAction: '<' } }); diff --git a/app/docker/components/datatables/containers-datatable/containersDatatableController.js b/app/docker/components/datatables/containers-datatable/containersDatatableController.js index d0c4bc27e..8d59803fe 100644 --- a/app/docker/components/datatables/containers-datatable/containersDatatableController.js +++ b/app/docker/components/datatables/containers-datatable/containersDatatableController.js @@ -1,6 +1,6 @@ angular.module('portainer.docker') -.controller('ContainersDatatableController', ['PaginationService', 'DatatableService', -function (PaginationService, DatatableService) { +.controller('ContainersDatatableController', ['PaginationService', 'DatatableService', 'EndpointProvider', +function (PaginationService, DatatableService, EndpointProvider) { var ctrl = this; @@ -10,7 +10,11 @@ function (PaginationService, DatatableService) { paginatedItemLimit: PaginationService.getPaginationLimit(this.tableKey), displayTextFilter: false, selectedItemCount: 0, - selectedItems: [] + selectedItems: [], + noStoppedItemsSelected: true, + noRunningItemsSelected: true, + noPausedItemsSelected: true, + publicURL: EndpointProvider.endpointPublicURL() }; this.settings = { @@ -45,6 +49,7 @@ function (PaginationService, DatatableService) { this.state.selectedItems.splice(this.state.selectedItems.indexOf(item), 1); this.state.selectedItemCount--; } + DatatableService.setDataTableSelectedItems(this.tableKey + '_' + EndpointProvider.endpointID(), this.state.selectedItems); }; this.selectItem = function(item) { @@ -139,12 +144,9 @@ function (PaginationService, DatatableService) { var availableStateFilters = []; for (var i = 0; i < this.dataset.length; i++) { var item = this.dataset[i]; - if (item.Checked) { - this.selectItem(item); - } availableStateFilters.push({ label: item.Status, display: true }); } - this.filters.state.values = _.uniqBy(availableStateFilters, 'label'); + this.filters.state.values = _.uniqBy(availableStateFilters, 'label'); }; this.updateStoredFilters = function(storedFilters) { @@ -160,6 +162,30 @@ function (PaginationService, DatatableService) { } }; + function selectPreviouslySelectedItem(item, storedSelectedItems) { + var selectedItem = _.find(storedSelectedItems, function(container) { + return item.Id === container.Id; + }); + + if (selectedItem) { + item.Checked = true; + ctrl.state.selectedItemCount++; + ctrl.state.selectedItems.push(item); + } + } + + this.selectItems = function(storedSelectedItems) { + for (var i = 0; i < this.dataset.length; i++) { + var item = this.dataset[i]; + selectPreviouslySelectedItem(item, storedSelectedItems); + } + + if (this.state.selectedItemCount > 0 && this.state.selectedItemCount === this.dataset.length) { + this.state.selectAll = true; + } + this.updateSelectionState(); + }; + this.$onInit = function() { setDefaults(this); this.prepareTableFromDataset(); @@ -170,6 +196,11 @@ function (PaginationService, DatatableService) { this.state.orderBy = storedOrder.orderBy; } + var storedSelectedItems = DatatableService.getDataTableSelectedItems(this.tableKey + '_' + EndpointProvider.endpointID()); + if (storedSelectedItems !== null) { + this.selectItems(storedSelectedItems); + } + var storedFilters = DatatableService.getDataTableFilters(this.tableKey); if (storedFilters !== null) { this.updateStoredFilters(storedFilters.state.values); diff --git a/app/docker/components/datatables/service-tasks-datatable/serviceTasksDatatable.html b/app/docker/components/datatables/service-tasks-datatable/serviceTasksDatatable.html new file mode 100644 index 000000000..71ee75b3f --- /dev/null +++ b/app/docker/components/datatables/service-tasks-datatable/serviceTasksDatatable.html @@ -0,0 +1,82 @@ +
+ + + + + + + + + + + + + + + + + + + + + + + + +
+ + Status + + + + + Filter + Filter + + + TaskActions + + Slot + + + + + + Node + + + + + + Last Update + + + +
+ {{ item.Status.State }} + + {{ item.Id }}Roz + {{ item.Id }}Doz + +
+ + + +
+
{{ item.Slot ? item.Slot : '-' }}{{ item.NodeId | tasknodename: $ctrl.nodes }}{{ item.Updated | getisodate }}
No task matching filter.
+
diff --git a/app/docker/components/datatables/service-tasks-datatable/serviceTasksDatatable.js b/app/docker/components/datatables/service-tasks-datatable/serviceTasksDatatable.js new file mode 100644 index 000000000..ce15b64db --- /dev/null +++ b/app/docker/components/datatables/service-tasks-datatable/serviceTasksDatatable.js @@ -0,0 +1,15 @@ +angular.module('portainer.docker').component('serviceTasksDatatable', { + templateUrl: 'app/docker/components/datatables/service-tasks-datatable/serviceTasksDatatable.html', + controller: 'ServiceTasksDatatableController', + bindings: { + dataset: '<', + serviceId: '<', + tableKey: '@', + orderBy: '@', + reverseOrder: '<', + nodes: '<', + agentProxy: '<', + textFilter: '=', + showTaskLogsButton: '<' + } +}); diff --git a/app/docker/components/datatables/service-tasks-datatable/serviceTasksDatatableController.js b/app/docker/components/datatables/service-tasks-datatable/serviceTasksDatatableController.js new file mode 100644 index 000000000..33eecffc4 --- /dev/null +++ b/app/docker/components/datatables/service-tasks-datatable/serviceTasksDatatableController.js @@ -0,0 +1,70 @@ +angular.module('portainer.docker') +.controller('ServiceTasksDatatableController', ['DatatableService', +function (DatatableService) { + var ctrl = this; + + this.state = { + orderBy: this.orderBy + }; + + this.filters = { + state: { + open: false, + enabled: false, + values: [] + } + }; + + this.applyFilters = function(item, index, array) { + var filters = ctrl.filters; + for (var i = 0; i < filters.state.values.length; i++) { + var filter = filters.state.values[i]; + if (item.Status.State === filter.label && filter.display) { + return true; + } + } + return false; + }; + + this.onStateFilterChange = function() { + var filters = this.filters.state.values; + var filtered = false; + for (var i = 0; i < filters.length; i++) { + var filter = filters[i]; + if (!filter.display) { + filtered = true; + } + } + this.filters.state.enabled = filtered; + }; + + this.changeOrderBy = function(orderField) { + this.state.reverseOrder = this.state.orderBy === orderField ? !this.state.reverseOrder : false; + this.state.orderBy = orderField; + DatatableService.setDataTableOrder(this.tableKey, orderField, this.state.reverseOrder); + }; + + this.prepareTableFromDataset = function() { + var availableStateFilters = []; + for (var i = 0; i < this.dataset.length; i++) { + var item = this.dataset[i]; + availableStateFilters.push({ label: item.Status.State, display: true }); + } + this.filters.state.values = _.uniqBy(availableStateFilters, 'label'); + }; + + this.$onInit = function() { + setDefaults(this); + this.prepareTableFromDataset(); + + var storedOrder = DatatableService.getDataTableOrder(this.tableKey); + if (storedOrder !== null) { + this.state.reverseOrder = storedOrder.reverse; + this.state.orderBy = storedOrder.orderBy; + } + }; + + function setDefaults(ctrl) { + ctrl.state.reverseOrder = ctrl.reverseOrder ? ctrl.reverseOrder : false; + } +}]); diff --git a/app/docker/components/datatables/services-datatable/actions/servicesDatatableActions.html b/app/docker/components/datatables/services-datatable/actions/servicesDatatableActions.html new file mode 100644 index 000000000..4ca83adcb --- /dev/null +++ b/app/docker/components/datatables/services-datatable/actions/servicesDatatableActions.html @@ -0,0 +1,15 @@ +
+
+ + +
+ +
diff --git a/app/docker/components/datatables/services-datatable/actions/servicesDatatableActions.js b/app/docker/components/datatables/services-datatable/actions/servicesDatatableActions.js new file mode 100644 index 000000000..32c16717f --- /dev/null +++ b/app/docker/components/datatables/services-datatable/actions/servicesDatatableActions.js @@ -0,0 +1,10 @@ +angular.module('portainer.docker').component('servicesDatatableActions', { + templateUrl: 'app/docker/components/datatables/services-datatable/actions/servicesDatatableActions.html', + controller: 'ServicesDatatableActionsController', + bindings: { + selectedItems: '=', + selectedItemCount: '=', + showUpdateAction: '<', + showAddAction: '<' + } +}); diff --git a/app/docker/components/datatables/services-datatable/actions/servicesDatatableActionsController.js b/app/docker/components/datatables/services-datatable/actions/servicesDatatableActionsController.js new file mode 100644 index 000000000..c2fa5f5ab --- /dev/null +++ b/app/docker/components/datatables/services-datatable/actions/servicesDatatableActionsController.js @@ -0,0 +1,81 @@ +angular.module('portainer.docker') +.controller('ServicesDatatableActionsController', ['$state', 'ServiceService', 'ServiceHelper', 'Notifications', 'ModalService', +function ($state, ServiceService, ServiceHelper, Notifications, ModalService) { + + this.scaleAction = function scaleService(service) { + var config = ServiceHelper.serviceToConfig(service.Model); + config.Mode.Replicated.Replicas = service.Replicas; + ServiceService.update(service, config) + .then(function success(data) { + Notifications.success('Service successfully scaled', 'New replica count: ' + service.Replicas); + $state.reload(); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to scale service'); + service.Scale = false; + service.Replicas = service.ReplicaCount; + }); + }; + + this.updateAction = function(selectedItems) { + ModalService.confirmServiceForceUpdate( + 'Do you want to force update of selected service(s)? All the tasks associated to the selected service(s) will be recreated.', + function onConfirm(confirmed) { + if(!confirmed) { return; } + forceUpdateServices(selectedItems); + } + ); + }; + + this.removeAction = function(selectedItems) { + ModalService.confirmDeletion( + 'Do you want to remove the selected service(s)? All the containers associated to the selected service(s) will be removed too.', + function onConfirm(confirmed) { + if(!confirmed) { return; } + removeServices(selectedItems); + } + ); + }; + + function forceUpdateServices(services) { + var actionCount = services.length; + angular.forEach(services, function (service) { + var config = ServiceHelper.serviceToConfig(service.Model); + // As explained in https://github.com/docker/swarmkit/issues/2364 ForceUpdate can accept a random + // value or an increment of the counter value to force an update. + config.TaskTemplate.ForceUpdate++; + ServiceService.update(service, config) + .then(function success(data) { + Notifications.success('Service successfully updated', service.Name); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to force update service', service.Name); + }) + .finally(function final() { + --actionCount; + if (actionCount === 0) { + $state.reload(); + } + }); + }); + } + + function removeServices(services) { + var actionCount = services.length; + angular.forEach(services, function (service) { + ServiceService.remove(service) + .then(function success() { + Notifications.success('Service successfully removed', service.Name); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to remove service'); + }) + .finally(function final() { + --actionCount; + if (actionCount === 0) { + $state.reload(); + } + }); + }); + } +}]); diff --git a/app/docker/components/datatables/services-datatable/servicesDatatable.html b/app/docker/components/datatables/services-datatable/servicesDatatable.html index c0bc7720e..b3ce6b77f 100644 --- a/app/docker/components/datatables/services-datatable/servicesDatatable.html +++ b/app/docker/components/datatables/services-datatable/servicesDatatable.html @@ -11,21 +11,12 @@ -
-
- - -
- -
+
{{ item.NodeId | tasknodename: $ctrl.nodes }} {{ item.Updated | getisodate }} - - View logs - - - Console - + View logs + View logs + Console
Container ID {{ task.Status.ContainerStatus.ContainerID }}
Task logs
+ + Type + + + + Control Ownership @@ -49,16 +57,20 @@
- + - {{ item.Name }} - - {{ item.Name }} + {{ item.Name }} + {{ item.Type === 1 ? 'Swarm' : 'Compose' }} + + Limited + Total @@ -68,10 +80,10 @@
Loading...Loading...
No stack available.No stack available.
diff --git a/app/portainer/components/datatables/stacks-datatable/stacksDatatable.js b/app/portainer/components/datatables/stacks-datatable/stacksDatatable.js index 121a1977c..6dfa9b7aa 100644 --- a/app/portainer/components/datatables/stacks-datatable/stacksDatatable.js +++ b/app/portainer/components/datatables/stacks-datatable/stacksDatatable.js @@ -10,7 +10,6 @@ angular.module('portainer.app').component('stacksDatatable', { reverseOrder: '<', showTextFilter: '<', showOwnershipColumn: '<', - removeAction: '<', - displayExternalStacks: '<' + removeAction: '<' } }); diff --git a/app/portainer/components/datatables/stacks-datatable/stacksDatatableController.js b/app/portainer/components/datatables/stacks-datatable/stacksDatatableController.js index e80470bc4..779d3bbd4 100644 --- a/app/portainer/components/datatables/stacks-datatable/stacksDatatableController.js +++ b/app/portainer/components/datatables/stacks-datatable/stacksDatatableController.js @@ -30,7 +30,7 @@ function (PaginationService, DatatableService) { this.selectAll = function() { for (var i = 0; i < this.state.filteredDataSet.length; i++) { var item = this.state.filteredDataSet[i]; - if (item.Id && item.Checked !== this.state.selectAll) { + if (!(item.External && item.Type === 2) && item.Checked !== this.state.selectAll) { item.Checked = this.state.selectAll; this.selectItem(item); } diff --git a/app/portainer/helpers/stackHelper.js b/app/portainer/helpers/stackHelper.js index 439efeb22..0469a882a 100644 --- a/app/portainer/helpers/stackHelper.js +++ b/app/portainer/helpers/stackHelper.js @@ -3,6 +3,19 @@ angular.module('portainer.app') 'use strict'; var helper = {}; + helper.getExternalStackNamesFromContainers = function(containers) { + var stackNames = []; + + for (var i = 0; i < containers.length; i++) { + var container = containers[i]; + if (!container.Labels || !container.Labels['com.docker.compose.project']) continue; + var stackName = container.Labels['com.docker.compose.project']; + stackNames.push(stackName); + } + + return _.uniq(stackNames); + }; + helper.getExternalStackNamesFromServices = function(services) { var stackNames = []; @@ -16,6 +29,6 @@ angular.module('portainer.app') return _.uniq(stackNames); }; - + return helper; }]); diff --git a/app/docker/models/stack.js b/app/portainer/models/stack.js similarity index 60% rename from app/docker/models/stack.js rename to app/portainer/models/stack.js index 7d1310246..78571d30d 100644 --- a/app/docker/models/stack.js +++ b/app/portainer/models/stack.js @@ -1,10 +1,18 @@ function StackViewModel(data) { this.Id = data.Id; + this.Type = data.Type; this.Name = data.Name; this.Checked = false; this.Env = data.Env ? data.Env : []; if (data.ResourceControl && data.ResourceControl.Id !== 0) { this.ResourceControl = new ResourceControlViewModel(data.ResourceControl); } - this.External = data.External; + this.External = false; +} + +function ExternalStackViewModel(name, type) { + this.Name = name; + this.Type = type; + this.External = true; + this.Checked = false; } diff --git a/app/portainer/rest/stack.js b/app/portainer/rest/stack.js index 1ebda7274..3d1efc5af 100644 --- a/app/portainer/rest/stack.js +++ b/app/portainer/rest/stack.js @@ -1,15 +1,13 @@ angular.module('portainer.app') -.factory('Stack', ['$resource', 'EndpointProvider', 'API_ENDPOINT_ENDPOINTS', function StackFactory($resource, EndpointProvider, API_ENDPOINT_ENDPOINTS) { +.factory('Stack', ['$resource', 'EndpointProvider', 'API_ENDPOINT_STACKS', function StackFactory($resource, EndpointProvider, API_ENDPOINT_STACKS) { 'use strict'; - return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/stacks/:id/:action', { - endpointId: EndpointProvider.endpointID - }, + return $resource(API_ENDPOINT_STACKS + '/:id/:action', {}, { get: { method: 'GET', params: { id: '@id' } }, query: { method: 'GET', isArray: true }, create: { method: 'POST', ignoreLoadingBar: true }, update: { method: 'PUT', params: { id: '@id' }, ignoreLoadingBar: true }, - remove: { method: 'DELETE', params: { id: '@id'} }, - getStackFile: { method: 'GET', params: { id : '@id', action: 'stackfile' } } + remove: { method: 'DELETE', params: { id: '@id', external: '@external', endpointId: '@endpointId' } }, + getStackFile: { method: 'GET', params: { id : '@id', action: 'file' } } }); }]); diff --git a/app/portainer/services/api/stackService.js b/app/portainer/services/api/stackService.js new file mode 100644 index 000000000..358b55d4b --- /dev/null +++ b/app/portainer/services/api/stackService.js @@ -0,0 +1,276 @@ +angular.module('portainer.app') +.factory('StackService', ['$q', 'Stack', 'ResourceControlService', 'FileUploadService', 'StackHelper', 'ServiceService', 'ContainerService', 'SwarmService', +function StackServiceFactory($q, Stack, ResourceControlService, FileUploadService, StackHelper, ServiceService, ContainerService, SwarmService) { + 'use strict'; + var service = {}; + + service.stack = function(id) { + var deferred = $q.defer(); + + Stack.get({ id: id }).$promise + .then(function success(data) { + var stack = new StackViewModel(data); + deferred.resolve(stack); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve stack details', err: err }); + }); + + return deferred.promise; + }; + + service.getStackFile = function(id) { + var deferred = $q.defer(); + + Stack.getStackFile({ id: id }).$promise + .then(function success(data) { + deferred.resolve(data.StackFileContent); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve stack content', err: err }); + }); + + return deferred.promise; + }; + + service.stacks = function(compose, swarm, endpointId) { + var deferred = $q.defer(); + + var queries = []; + if (compose) { + queries.push(service.composeStacks(true, { EndpointID: endpointId })); + } + if (swarm) { + queries.push(service.swarmStacks(true)); + } + + $q.all(queries) + .then(function success(data) { + var stacks = []; + if (data[0]) { + stacks = stacks.concat(data[0]); + } + if (data[1]) { + stacks = stacks.concat(data[1]); + } + deferred.resolve(stacks); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve stacks', err: err }); + }); + + return deferred.promise; + }; + + service.externalSwarmStacks = function() { + var deferred = $q.defer(); + + ServiceService.services() + .then(function success(data) { + var services = data; + var stackNames = StackHelper.getExternalStackNamesFromServices(services); + var stacks = stackNames.map(function (name) { + return new ExternalStackViewModel(name, 1); + }); + deferred.resolve(stacks); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve external stacks', err: err }); + }); + + return deferred.promise; + }; + + service.externalComposeStacks = function() { + var deferred = $q.defer(); + + ContainerService.containers(1) + .then(function success(data) { + var containers = data; + var stackNames = StackHelper.getExternalStackNamesFromContainers(containers); + var stacks = stackNames.map(function (name) { + return new ExternalStackViewModel(name, 2); + }); + deferred.resolve(stacks); + + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve external stacks', err: err }); + }); + + return deferred.promise; + }; + + service.composeStacks = function(includeExternalStacks, filters) { + var deferred = $q.defer(); + + $q.all({ + stacks: Stack.query({filters: filters}).$promise, + externalStacks: includeExternalStacks ? service.externalComposeStacks() : [] + }) + .then(function success(data) { + var stacks = data.stacks.map(function (item) { + item.External = false; + return new StackViewModel(item); + }); + var externalStacks = data.externalStacks; + + var result = _.unionWith(stacks, externalStacks, function(a, b) { return a.Name === b.Name; }); + deferred.resolve(result); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve stacks', err: err }); + }); + + return deferred.promise; + }; + + service.swarmStacks = function(includeExternalStacks) { + var deferred = $q.defer(); + + SwarmService.swarm() + .then(function success(data) { + var swarm = data; + var filters = { SwarmID: swarm.Id }; + + return $q.all({ + stacks: Stack.query({ filters: filters }).$promise, + externalStacks: includeExternalStacks ? service.externalSwarmStacks() : [] + }); + }) + .then(function success(data) { + var stacks = data.stacks.map(function (item) { + item.External = false; + return new StackViewModel(item); + }); + var externalStacks = data.externalStacks; + + var result = _.unionWith(stacks, externalStacks, function(a, b) { return a.Name === b.Name; }); + deferred.resolve(result); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve stacks', err: err }); + }); + + return deferred.promise; + }; + + service.remove = function(stack, external, endpointId) { + var deferred = $q.defer(); + + Stack.remove({ id: stack.Id ? stack.Id : stack.Name, external: external, endpointId: endpointId }).$promise + .then(function success(data) { + if (stack.ResourceControl && stack.ResourceControl.Id) { + return ResourceControlService.deleteResourceControl(stack.ResourceControl.Id); + } + }) + .then(function success() { + deferred.resolve(); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to remove the stack', err: err }); + }); + + return deferred.promise; + }; + + service.updateStack = function(id, stackFile, env, prune) { + return Stack.update({ id: id, StackFileContent: stackFile, Env: env, Prune: prune}).$promise; + }; + + service.createComposeStackFromFileUpload = function(name, stackFile, endpointId) { + return FileUploadService.createComposeStack(name, stackFile, endpointId); + }; + + service.createSwarmStackFromFileUpload = function(name, stackFile, env, endpointId) { + var deferred = $q.defer(); + + SwarmService.swarm() + .then(function success(data) { + var swarm = data; + return FileUploadService.createSwarmStack(name, swarm.Id, stackFile, env, endpointId); + }) + .then(function success(data) { + deferred.resolve(data.data); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to create the stack', err: err }); + }); + + return deferred.promise; + }; + + service.createComposeStackFromFileContent = function(name, stackFileContent, endpointId) { + var payload = { + Name: name, + StackFileContent: stackFileContent + }; + return Stack.create({ method: 'string', type: 2, endpointId: endpointId }, payload).$promise; + }; + + service.createSwarmStackFromFileContent = function(name, stackFileContent, env, endpointId) { + var deferred = $q.defer(); + + SwarmService.swarm() + .then(function success(data) { + var swarm = data; + var payload = { + Name: name, + SwarmID: swarm.Id, + StackFileContent: stackFileContent, + Env: env + }; + return Stack.create({ method: 'string', type: 1, endpointId: endpointId }, payload).$promise; + }) + .then(function success(data) { + deferred.resolve(data); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to create the stack', err: err }); + }); + + return deferred.promise; + }; + + service.createComposeStackFromGitRepository = function(name, repositoryOptions, endpointId) { + var payload = { + Name: name, + RepositoryURL: repositoryOptions.RepositoryURL, + ComposeFilePathInRepository: repositoryOptions.ComposeFilePathInRepository, + RepositoryAuthentication: repositoryOptions.RepositoryAuthentication, + RepositoryUsername: repositoryOptions.RepositoryUsername, + RepositoryPassword: repositoryOptions.RepositoryPassword + }; + return Stack.create({ method: 'repository', type: 2, endpointId: endpointId }, payload).$promise; + }; + + service.createSwarmStackFromGitRepository = function(name, repositoryOptions, env, endpointId) { + var deferred = $q.defer(); + + SwarmService.swarm() + .then(function success(data) { + var swarm = data; + var payload = { + Name: name, + SwarmID: swarm.Id, + RepositoryURL: repositoryOptions.RepositoryURL, + ComposeFilePathInRepository: repositoryOptions.ComposeFilePathInRepository, + RepositoryAuthentication: repositoryOptions.RepositoryAuthentication, + RepositoryUsername: repositoryOptions.RepositoryUsername, + RepositoryPassword: repositoryOptions.RepositoryPassword, + Env: env + }; + return Stack.create({ method: 'repository', type: 1, endpointId: endpointId }, payload).$promise; + }) + .then(function success(data) { + deferred.resolve(data); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to create the stack', err: err }); + }); + + return deferred.promise; + }; + + return service; +}]); diff --git a/app/portainer/services/datatableService.js b/app/portainer/services/datatableService.js index 67f723349..7809c4b1a 100644 --- a/app/portainer/services/datatableService.js +++ b/app/portainer/services/datatableService.js @@ -33,5 +33,21 @@ function DatatableServiceFactory(LocalStorage) { LocalStorage.storeDataTableOrder(key, filter); }; + service.setDataTableExpandedItems = function(key, expandedItems) { + LocalStorage.storeDataTableExpandedItems(key, expandedItems); + }; + + service.getDataTableExpandedItems = function(key) { + return LocalStorage.getDataTableExpandedItems(key); + }; + + service.setDataTableSelectedItems = function(key, selectedItems) { + LocalStorage.storeDataTableSelectedItems(key, selectedItems); + }; + + service.getDataTableSelectedItems = function(key) { + return LocalStorage.getDataTableSelectedItems(key); + }; + return service; }]); diff --git a/app/portainer/services/fileUpload.js b/app/portainer/services/fileUpload.js index c5413149c..d71f7f244 100644 --- a/app/portainer/services/fileUpload.js +++ b/app/portainer/services/fileUpload.js @@ -28,10 +28,9 @@ angular.module('portainer.app') }); }; - service.createStack = function(stackName, swarmId, file, env) { - var endpointID = EndpointProvider.endpointID(); + service.createSwarmStack = function(stackName, swarmId, file, env, endpointId) { return Upload.upload({ - url: 'api/endpoints/' + endpointID + '/stacks?method=file', + url: 'api/stacks?method=file&type=1&endpointId=' + endpointId, data: { file: file, Name: stackName, @@ -42,6 +41,17 @@ angular.module('portainer.app') }); }; + service.createComposeStack = function(stackName, file, endpointId) { + return Upload.upload({ + url: 'api/stacks?method=file&type=2&endpointId=' + endpointId, + data: { + file: file, + Name: stackName + }, + ignoreLoadingBar: true + }); + }; + service.createEndpoint = function(name, type, URL, PublicURL, groupID, TLS, TLSSkipVerify, TLSSkipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile) { return Upload.upload({ url: 'api/endpoints', diff --git a/app/portainer/services/localStorage.js b/app/portainer/services/localStorage.js index 1487a20ea..c94e0cadc 100644 --- a/app/portainer/services/localStorage.js +++ b/app/portainer/services/localStorage.js @@ -59,6 +59,18 @@ angular.module('portainer.app') storeDataTableSettings: function(key, data) { localStorageService.set('datatable_settings_' + key, data); }, + getDataTableExpandedItems: function(key) { + return localStorageService.get('datatable_expandeditems_' + key); + }, + storeDataTableExpandedItems: function(key, data) { + localStorageService.set('datatable_expandeditems_' + key, data); + }, + getDataTableSelectedItems: function(key) { + return localStorageService.get('datatable_selecteditems_' + key); + }, + storeDataTableSelectedItems: function(key, data) { + localStorageService.set('datatable_selecteditems_' + key, data); + }, storeSwarmVisualizerSettings: function(key, data) { localStorageService.set('swarmvisualizer_' + key, data); }, diff --git a/app/docker/views/stacks/create/createStackController.js b/app/portainer/views/stacks/create/createStackController.js similarity index 57% rename from app/docker/views/stacks/create/createStackController.js rename to app/portainer/views/stacks/create/createStackController.js index 711e3c1c7..4a02d5add 100644 --- a/app/docker/views/stacks/create/createStackController.js +++ b/app/portainer/views/stacks/create/createStackController.js @@ -1,6 +1,6 @@ -angular.module('portainer.docker') -.controller('CreateStackController', ['$scope', '$state', 'StackService', 'Authentication', 'Notifications', 'FormValidator', 'ResourceControlService', 'FormHelper', -function ($scope, $state, StackService, Authentication, Notifications, FormValidator, ResourceControlService, FormHelper) { +angular.module('portainer.app') +.controller('CreateStackController', ['$scope', '$state', 'StackService', 'Authentication', 'Notifications', 'FormValidator', 'ResourceControlService', 'FormHelper', 'EndpointProvider', +function ($scope, $state, StackService, Authentication, Notifications, FormValidator, ResourceControlService, FormHelper, EndpointProvider) { $scope.formValues = { Name: '', @@ -18,7 +18,8 @@ function ($scope, $state, StackService, Authentication, Notifications, FormValid $scope.state = { Method: 'editor', formValidationError: '', - actionInProgress: false + actionInProgress: false, + StackType: null }; $scope.addEnvironmentVariable = function() { @@ -41,15 +42,16 @@ function ($scope, $state, StackService, Authentication, Notifications, FormValid return true; } - function createStack(name, method) { + function createSwarmStack(name, method) { var env = FormHelper.removeInvalidEnvVars($scope.formValues.Env); + var endpointId = EndpointProvider.endpointID(); if (method === 'editor') { var stackFileContent = $scope.formValues.StackFileContent; - return StackService.createStackFromFileContent(name, stackFileContent, env); + return StackService.createSwarmStackFromFileContent(name, stackFileContent, env, endpointId); } else if (method === 'upload') { var stackFile = $scope.formValues.StackFile; - return StackService.createStackFromFileUpload(name, stackFile, env); + return StackService.createSwarmStackFromFileUpload(name, stackFile, env, endpointId); } else if (method === 'repository') { var repositoryOptions = { RepositoryURL: $scope.formValues.RepositoryURL, @@ -58,7 +60,28 @@ function ($scope, $state, StackService, Authentication, Notifications, FormValid RepositoryUsername: $scope.formValues.RepositoryUsername, RepositoryPassword: $scope.formValues.RepositoryPassword }; - return StackService.createStackFromGitRepository(name, repositoryOptions, env); + return StackService.createSwarmStackFromGitRepository(name, repositoryOptions, env, endpointId); + } + } + + function createComposeStack(name, method) { + var endpointId = EndpointProvider.endpointID(); + + if (method === 'editor') { + var stackFileContent = $scope.formValues.StackFileContent; + return StackService.createComposeStackFromFileContent(name, stackFileContent, endpointId); + } else if (method === 'upload') { + var stackFile = $scope.formValues.StackFile; + return StackService.createComposeStackFromFileUpload(name, stackFile, endpointId); + } else if (method === 'repository') { + var repositoryOptions = { + RepositoryURL: $scope.formValues.RepositoryURL, + ComposeFilePathInRepository: $scope.formValues.ComposeFilePathInRepository, + RepositoryAuthentication: $scope.formValues.RepositoryAuthentication, + RepositoryUsername: $scope.formValues.RepositoryUsername, + RepositoryPassword: $scope.formValues.RepositoryPassword + }; + return StackService.createComposeStackFromGitRepository(name, repositoryOptions, endpointId); } } @@ -80,17 +103,22 @@ function ($scope, $state, StackService, Authentication, Notifications, FormValid return; } + var type = $scope.state.StackType; + var action = createSwarmStack; + if (type === 2) { + action = createComposeStack; + } $scope.state.actionInProgress = true; - createStack(name, method) + action(name, method) .then(function success(data) { return ResourceControlService.applyResourceControl('stack', name, userId, accessControlData, []); }) .then(function success() { Notifications.success('Stack successfully deployed'); - $state.go('docker.stacks'); + $state.go('portainer.stacks'); }) .catch(function error(err) { - Notifications.warning('Deployment error', err.err.data.err); + Notifications.warning('Deployment error', type === 1 ? err.err.data.err : err.data.err); }) .finally(function final() { $scope.state.actionInProgress = false; @@ -100,4 +128,14 @@ function ($scope, $state, StackService, Authentication, Notifications, FormValid $scope.editorUpdate = function(cm) { $scope.formValues.StackFileContent = cm.getValue(); }; + + function initView() { + var endpointMode = $scope.applicationState.endpoint.mode; + $scope.state.StackType = 2; + if (endpointMode.provider === 'DOCKER_SWARM_MODE' && endpointMode.role === 'MANAGER') { + $scope.state.StackType = 1; + } + } + + initView(); }]); diff --git a/app/docker/views/stacks/create/createstack.html b/app/portainer/views/stacks/create/createstack.html similarity index 80% rename from app/docker/views/stacks/create/createstack.html rename to app/portainer/views/stacks/create/createstack.html index 823b5291f..d9fcea05b 100644 --- a/app/docker/views/stacks/create/createstack.html +++ b/app/portainer/views/stacks/create/createstack.html @@ -1,7 +1,7 @@ - Stacks > Add stack + Stacks > Add stack @@ -19,9 +19,12 @@
- + This stack will be deployed using the equivalent of the docker stack deploy command. + + This stack will be deployed using the equivalent of docker-compose. Only Compose file format version 2 is supported at the moment. +
@@ -156,34 +159,36 @@
-
- Environment -
-
-
- - - add environment variable - +
+
+ Environment
- -
-
-
- name - -
-
- value - -
- +
+
+ + + add environment variable +
+ +
+
+
+ name + +
+
+ value + +
+ +
+
+
-
diff --git a/app/portainer/views/stacks/edit/stack.html b/app/portainer/views/stacks/edit/stack.html new file mode 100644 index 000000000..3dd4266e8 --- /dev/null +++ b/app/portainer/views/stacks/edit/stack.html @@ -0,0 +1,158 @@ + + + + + + + + Stacks > {{ stackName }} + + + +
+
+ + +
+ Information +
+
+ +

+ + This stack was created outside of Portainer. Control over this stack is limited. +

+
+
+
+
+
+
+ + + + + + +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ + + +
+
+ + You can get more information about Compose file format in the official documentation. + +
+
+
+ +
+
+ +
+
+ Environment +
+
+
+ + + add environment variable + +
+ +
+
+
+ name + +
+
+ value + +
+ +
+
+ +
+
+ + +
+
+ Options +
+
+
+ + +
+
+
+ +
+ Actions +
+
+
+ +
+
+
+
+
+
+
diff --git a/app/portainer/views/stacks/edit/stackController.js b/app/portainer/views/stacks/edit/stackController.js new file mode 100644 index 000000000..8985edbef --- /dev/null +++ b/app/portainer/views/stacks/edit/stackController.js @@ -0,0 +1,171 @@ +angular.module('portainer.app') +.controller('StackController', ['$q', '$scope', '$state', '$transition$', 'StackService', 'NodeService', 'ServiceService', 'TaskService', 'ContainerService', 'ServiceHelper', 'TaskHelper', 'Notifications', 'FormHelper', +function ($q, $scope, $state, $transition$, StackService, NodeService, ServiceService, TaskService, ContainerService, ServiceHelper, TaskHelper, Notifications, FormHelper) { + + $scope.state = { + actionInProgress: false, + externalStack: false + }; + + $scope.formValues = { + Prune: false + }; + + $scope.deployStack = function () { + var stackFile = $scope.stackFileContent; + var env = FormHelper.removeInvalidEnvVars($scope.stack.Env); + var prune = $scope.formValues.Prune; + + $scope.state.actionInProgress = true; + StackService.updateStack($scope.stack.Id, stackFile, env, prune) + .then(function success(data) { + Notifications.success('Stack successfully deployed'); + $state.reload(); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to create stack'); + }) + .finally(function final() { + $scope.state.actionInProgress = false; + }); + }; + + $scope.addEnvironmentVariable = function() { + $scope.stack.Env.push({ name: '', value: ''}); + }; + + $scope.removeEnvironmentVariable = function(index) { + $scope.stack.Env.splice(index, 1); + }; + + $scope.editorUpdate = function(cm) { + $scope.stackFileContent = cm.getValue(); + }; + + function loadStack(id) { + var agentProxy = $scope.applicationState.endpoint.mode.agentProxy; + + StackService.stack(id) + .then(function success(data) { + var stack = data; + $scope.stack = stack; + + return $q.all({ + stackFile: StackService.getStackFile(id), + resources: stack.Type === 1 ? retrieveSwarmStackResources(stack.Name, agentProxy) : retrieveComposeStackResources(stack.Name) + }); + }) + .then(function success(data) { + $scope.stackFileContent = data.stackFile; + if ($scope.stack.Type === 1) { + assignSwarmStackResources(data.resources, agentProxy); + } else { + assignComposeStackResources(data.resources); + } + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to retrieve stack details'); + }); + } + + function retrieveSwarmStackResources(stackName, agentProxy) { + var stackFilter = { + label: ['com.docker.stack.namespace=' + stackName] + }; + + return $q.all({ + services: ServiceService.services(stackFilter), + tasks: TaskService.tasks(stackFilter), + containers: agentProxy ? ContainerService.containers(1) : [], + nodes: NodeService.nodes() + }); + } + + function assignSwarmStackResources(resources, agentProxy) { + var services = resources.services; + var tasks = resources.tasks; + + if (agentProxy) { + var containers = resources.containers; + for (var j = 0; j < tasks.length; j++) { + var task = tasks[j]; + TaskHelper.associateContainerToTask(task, containers); + } + } + + for (var i = 0; i < services.length; i++) { + var service = services[i]; + ServiceHelper.associateTasksToService(service, tasks); + } + + $scope.nodes = resources.nodes; + $scope.tasks = tasks; + $scope.services = services; + } + + function retrieveComposeStackResources(stackName) { + var stackFilter = { + label: ['com.docker.compose.project=' + stackName] + }; + + return $q.all({ + containers: ContainerService.containers(1, stackFilter) + }); + } + + function assignComposeStackResources(resources) { + $scope.containers = resources.containers; + } + + function loadExternalStack(name) { + var stackType = $transition$.params().type; + if (!stackType || (stackType !== '1' && stackType !== '2')) { + Notifications.error('Failure', err, 'Invalid type URL parameter.'); + return; + } + + if (stackType === '1') { + loadExternalSwarmStack(name); + } else { + loadExternalComposeStack(name); + } + } + + function loadExternalSwarmStack(name) { + var agentProxy = $scope.applicationState.endpoint.mode.agentProxy; + + retrieveSwarmStackResources(name) + .then(function success(data) { + assignSwarmStackResources(data); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to retrieve stack details'); + }); + } + + function loadExternalComposeStack(name) { + retrieveComposeStackResources(name) + .then(function success(data) { + assignComposeStackResources(data); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to retrieve stack details'); + }); + } + + function initView() { + var stackName = $transition$.params().name; + $scope.stackName = stackName; + var external = $transition$.params().external; + + if (external === 'true') { + $scope.state.externalStack = true; + loadExternalStack(stackName); + } else { + var stackId = $transition$.params().id; + loadStack(stackId); + } + } + + initView(); +}]); diff --git a/app/portainer/views/stacks/stacks.html b/app/portainer/views/stacks/stacks.html new file mode 100644 index 000000000..b1b2c69c5 --- /dev/null +++ b/app/portainer/views/stacks/stacks.html @@ -0,0 +1,20 @@ + + + + + + + Stacks + + +
+
+ +
+
diff --git a/app/docker/views/stacks/stacksController.js b/app/portainer/views/stacks/stacksController.js similarity index 73% rename from app/docker/views/stacks/stacksController.js rename to app/portainer/views/stacks/stacksController.js index e51495dbf..52482ec0c 100644 --- a/app/docker/views/stacks/stacksController.js +++ b/app/portainer/views/stacks/stacksController.js @@ -1,11 +1,6 @@ -angular.module('portainer.docker') -.controller('StacksController', ['$scope', '$state', 'Notifications', 'StackService', 'ModalService', -function ($scope, $state, Notifications, StackService, ModalService) { - $scope.state = { - displayInformationPanel: false, - displayExternalStacks: true - }; - +angular.module('portainer.app') +.controller('StacksController', ['$scope', '$state', 'Notifications', 'StackService', 'ModalService', 'EndpointProvider', +function ($scope, $state, Notifications, StackService, ModalService, EndpointProvider) { $scope.removeAction = function(selectedItems) { ModalService.confirmDeletion( 'Do you want to remove the selected stack(s)? Associated services will be removed as well.', @@ -17,9 +12,10 @@ function ($scope, $state, Notifications, StackService, ModalService) { }; function deleteSelectedStacks(stacks) { + var endpointId = EndpointProvider.endpointID(); var actionCount = stacks.length; angular.forEach(stacks, function (stack) { - StackService.remove(stack) + StackService.remove(stack, stack.External, endpointId) .then(function success() { Notifications.success('Stack successfully removed', stack.Name); var index = $scope.stacks.indexOf(stack); @@ -38,16 +34,16 @@ function ($scope, $state, Notifications, StackService, ModalService) { } function initView() { - StackService.stacks(true) + var endpointMode = $scope.applicationState.endpoint.mode; + var endpointId = EndpointProvider.endpointID(); + + StackService.stacks( + true, + endpointMode.provider === 'DOCKER_SWARM_MODE' && endpointMode.role === 'MANAGER', + endpointId + ) .then(function success(data) { var stacks = data; - for (var i = 0; i < stacks.length; i++) { - var stack = stacks[i]; - if (stack.External) { - $scope.state.displayInformationPanel = true; - break; - } - } $scope.stacks = stacks; }) .catch(function error(err) { From 1e12057cddf4458d8817c9b7bdfddb7efa00fa3e Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Mon, 11 Jun 2018 17:58:46 +0200 Subject: [PATCH 15/37] fix(api): review security policies when creating/updating a resource control (#1964) --- .../resourcecontrol_update.go | 14 +++--- api/http/security/authorization.go | 44 +++++++++++++++++++ 2 files changed, 53 insertions(+), 5 deletions(-) diff --git a/api/http/handler/resourcecontrols/resourcecontrol_update.go b/api/http/handler/resourcecontrols/resourcecontrol_update.go index 1a5aa29d8..c2a580cb0 100644 --- a/api/http/handler/resourcecontrols/resourcecontrol_update.go +++ b/api/http/handler/resourcecontrols/resourcecontrol_update.go @@ -43,6 +43,15 @@ func (handler *Handler) resourceControlUpdate(w http.ResponseWriter, r *http.Req return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a resource control with with the specified identifier inside the database", err} } + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} + } + + if !security.AuthorizedResourceControlAccess(resourceControl, securityContext) { + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to update the resource control", portainer.ErrResourceAccessDenied} + } + resourceControl.AdministratorsOnly = payload.AdministratorsOnly var userAccesses = make([]portainer.UserResourceAccess, 0) @@ -65,11 +74,6 @@ func (handler *Handler) resourceControlUpdate(w http.ResponseWriter, r *http.Req } resourceControl.TeamAccesses = teamAccesses - securityContext, err := security.RetrieveRestrictedRequestContext(r) - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} - } - if !security.AuthorizedResourceControlUpdate(resourceControl, securityContext) { return &httperror.HandlerError{http.StatusForbidden, "Permission denied to update the resource control", portainer.ErrResourceAccessDenied} } diff --git a/api/http/security/authorization.go b/api/http/security/authorization.go index a2efe216a..155869632 100644 --- a/api/http/security/authorization.go +++ b/api/http/security/authorization.go @@ -40,6 +40,43 @@ func AuthorizedResourceControlDeletion(resourceControl *portainer.ResourceContro return false } +// AuthorizedResourceControlAccess checks whether the user can alter an existing resource control. +func AuthorizedResourceControlAccess(resourceControl *portainer.ResourceControl, context *RestrictedRequestContext) bool { + if context.IsAdmin { + return true + } + + if resourceControl.AdministratorsOnly { + return false + } + + authorizedTeamAccess := false + for _, access := range resourceControl.TeamAccesses { + for _, membership := range context.UserMemberships { + if membership.TeamID == access.TeamID { + authorizedTeamAccess = true + break + } + } + } + if !authorizedTeamAccess { + return false + } + + authorizedUserAccess := false + for _, access := range resourceControl.UserAccesses { + if context.UserID == access.UserID { + authorizedUserAccess = true + break + } + } + if !authorizedUserAccess { + return false + } + + return true +} + // AuthorizedResourceControlUpdate ensure that the user can update a resource control object. // It reuses the creation restrictions and adds extra checks. // A non-administrator user cannot update a resource control where: @@ -56,7 +93,9 @@ func AuthorizedResourceControlUpdate(resourceControl *portainer.ResourceControl, // AuthorizedResourceControlCreation ensure that the user can create a resource control object. // A non-administrator user cannot create a resource control where: // * the AdministratorsOnly flag is set +// * he wants to create a resource control without any user/team accesses // * he wants to add more than one user in the user accesses +// * he wants tp add a user in the user accesses that is not corresponding to its id // * he wants to add a team he is not a member of func AuthorizedResourceControlCreation(resourceControl *portainer.ResourceControl, context *RestrictedRequestContext) bool { if context.IsAdmin { @@ -69,6 +108,11 @@ func AuthorizedResourceControlCreation(resourceControl *portainer.ResourceContro userAccessesCount := len(resourceControl.UserAccesses) teamAccessesCount := len(resourceControl.TeamAccesses) + + if userAccessesCount == 0 && teamAccessesCount == 0 { + return false + } + if userAccessesCount > 1 || (userAccessesCount == 1 && teamAccessesCount == 1) { return false } From b349f16090886cf4d7905c160df0e4c1b775ee14 Mon Sep 17 00:00:00 2001 From: cedric-crouzet-penbase Date: Wed, 13 Jun 2018 16:04:24 +0200 Subject: [PATCH 16/37] fix(containers): remove hardcoded container stop/restart timeout REST call to stop/restart a container overrides the default stop timeout (before kill) with hardcoded 5 seconds. Containers already have a default stop timeout handled by the engine API (https://github.com/moby/moby/blob/master/client/container_stop.go). With this hardcoded 5 seconds, the containers get killed after 5 seconds even if they define a custom greater stop timeout. Another solution would be to not hardcode the 5 seconds but rather use a global editable setting. --- app/docker/rest/container.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/docker/rest/container.js b/app/docker/rest/container.js index 9f35d18d8..d100d5a1c 100644 --- a/app/docker/rest/container.js +++ b/app/docker/rest/container.js @@ -15,10 +15,10 @@ function ContainerFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) { method: 'GET', params: { action: 'json' } }, stop: { - method: 'POST', params: { id: '@id', t: 5, action: 'stop' } + method: 'POST', params: { id: '@id', action: 'stop' } }, restart: { - method: 'POST', params: { id: '@id', t: 5, action: 'restart' } + method: 'POST', params: { id: '@id', action: 'restart' } }, kill: { method: 'POST', params: { id: '@id', action: 'kill' } From 5e73a4947369535ff43bc304ea3fadd9714cd828 Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Fri, 15 Jun 2018 09:18:25 +0200 Subject: [PATCH 17/37] feat(tags): add the ability to manage tags (#1971) * feat(tags): add the ability to manage tags * feat(tags): update tag selector UX * refactor(app): remove unused ui-select library --- api/bolt/datastore.go | 7 +- api/bolt/internal/internal.go | 10 ++ api/bolt/migrate_dbversion11.go | 37 ++++++ api/bolt/migrator.go | 14 +++ api/bolt/tag_service.go | 71 +++++++++++ api/cmd/portainer/main.go | 3 + api/errors.go | 5 + .../endpointgroups/endpointgroup_create.go | 7 +- .../endpointgroups/endpointgroup_update.go | 6 +- api/http/handler/endpoints/endpoint_create.go | 11 ++ api/http/handler/endpoints/endpoint_update.go | 5 + api/http/handler/handler.go | 8 +- api/http/handler/tags/handler.go | 31 +++++ api/http/handler/tags/tag_create.go | 53 ++++++++ api/http/handler/tags/tag_delete.go | 25 ++++ api/http/handler/tags/tag_list.go | 18 +++ api/http/server.go | 30 +++-- api/portainer.go | 24 +++- api/swagger.yaml | 119 ++++++++++++++++++ app/constants.js | 1 + app/portainer/__module.js | 12 ++ .../tags-datatable/tagsDatatable.html | 84 +++++++++++++ .../tags-datatable/tagsDatatable.js | 14 +++ .../components/forms/group-form/group-form.js | 1 + .../forms/group-form/groupForm.html | 36 ++---- .../components/tag-selector/tag-selector.js | 8 ++ .../components/tag-selector/tagSelector.html | 38 ++++++ .../tag-selector/tagSelectorController.js | 32 +++++ app/portainer/models/group.js | 8 +- app/portainer/models/tag.js | 4 + app/portainer/rest/tag.js | 9 ++ app/portainer/services/api/endpointService.js | 10 +- app/portainer/services/api/tagService.js | 49 ++++++++ app/portainer/services/fileUpload.js | 7 +- .../create/createEndpointController.js | 33 +++-- .../endpoints/create/createendpoint.html | 20 ++- .../views/endpoints/edit/endpoint.html | 10 +- .../endpoints/edit/endpointController.js | 9 +- .../groups/create/createGroupController.js | 20 ++- .../views/groups/create/creategroup.html | 1 + app/portainer/views/groups/edit/group.html | 1 + .../views/groups/edit/groupController.js | 16 +-- .../init/endpoint/initEndpointController.js | 4 +- app/portainer/views/sidebar/sidebar.html | 5 +- app/portainer/views/tags/tags.html | 58 +++++++++ app/portainer/views/tags/tagsController.js | 58 +++++++++ assets/css/app.css | 21 +++- package.json | 1 - vendor.yml | 2 - yarn.lock | 4 - 50 files changed, 942 insertions(+), 118 deletions(-) create mode 100644 api/bolt/migrate_dbversion11.go create mode 100644 api/bolt/tag_service.go create mode 100644 api/http/handler/tags/handler.go create mode 100644 api/http/handler/tags/tag_create.go create mode 100644 api/http/handler/tags/tag_delete.go create mode 100644 api/http/handler/tags/tag_list.go create mode 100644 app/portainer/components/datatables/tags-datatable/tagsDatatable.html create mode 100644 app/portainer/components/datatables/tags-datatable/tagsDatatable.js create mode 100644 app/portainer/components/tag-selector/tag-selector.js create mode 100644 app/portainer/components/tag-selector/tagSelector.html create mode 100644 app/portainer/components/tag-selector/tagSelectorController.js create mode 100644 app/portainer/models/tag.js create mode 100644 app/portainer/rest/tag.js create mode 100644 app/portainer/services/api/tagService.js create mode 100644 app/portainer/views/tags/tags.html create mode 100644 app/portainer/views/tags/tagsController.js diff --git a/api/bolt/datastore.go b/api/bolt/datastore.go index 8017d8add..37ad96ce7 100644 --- a/api/bolt/datastore.go +++ b/api/bolt/datastore.go @@ -27,6 +27,7 @@ type Store struct { RegistryService *RegistryService DockerHubService *DockerHubService StackService *StackService + TagService *TagService db *bolt.DB checkForDataMigration bool @@ -45,6 +46,7 @@ const ( registryBucketName = "registries" dockerhubBucketName = "dockerhub" stackBucketName = "stacks" + tagBucketName = "tags" ) // NewStore initializes a new Store and the associated services @@ -62,6 +64,7 @@ func NewStore(storePath string) (*Store, error) { RegistryService: &RegistryService{}, DockerHubService: &DockerHubService{}, StackService: &StackService{}, + TagService: &TagService{}, } store.UserService.store = store store.TeamService.store = store @@ -74,6 +77,7 @@ func NewStore(storePath string) (*Store, error) { store.RegistryService.store = store store.DockerHubService.store = store store.StackService.store = store + store.TagService.store = store _, err := os.Stat(storePath + "/" + databaseFileName) if err != nil && os.IsNotExist(err) { @@ -99,7 +103,7 @@ func (store *Store) Open() error { bucketsToCreate := []string{versionBucketName, userBucketName, teamBucketName, endpointBucketName, endpointGroupBucketName, resourceControlBucketName, teamMembershipBucketName, settingsBucketName, - registryBucketName, dockerhubBucketName, stackBucketName} + registryBucketName, dockerhubBucketName, stackBucketName, tagBucketName} return db.Update(func(tx *bolt.Tx) error { @@ -128,6 +132,7 @@ func (store *Store) Init() error { Labels: []portainer.Pair{}, AuthorizedUsers: []portainer.UserID{}, AuthorizedTeams: []portainer.TeamID{}, + Tags: []string{}, } return store.EndpointGroupService.CreateEndpointGroup(unassignedGroup) diff --git a/api/bolt/internal/internal.go b/api/bolt/internal/internal.go index b247268ee..7ed0d72be 100644 --- a/api/bolt/internal/internal.go +++ b/api/bolt/internal/internal.go @@ -107,6 +107,16 @@ func UnmarshalDockerHub(data []byte, settings *portainer.DockerHub) error { return json.Unmarshal(data, settings) } +// MarshalTag encodes a Tag object to binary format. +func MarshalTag(tag *portainer.Tag) ([]byte, error) { + return json.Marshal(tag) +} + +// UnmarshalTag decodes a Tag object from a binary data. +func UnmarshalTag(data []byte, tag *portainer.Tag) error { + return json.Unmarshal(data, tag) +} + // Itob returns an 8-byte big endian representation of v. // This function is typically used for encoding integer IDs to byte slices // so that they can be used as BoltDB keys. diff --git a/api/bolt/migrate_dbversion11.go b/api/bolt/migrate_dbversion11.go new file mode 100644 index 000000000..a16cc093b --- /dev/null +++ b/api/bolt/migrate_dbversion11.go @@ -0,0 +1,37 @@ +package bolt + +func (m *Migrator) updateEndpointsToVersion12() error { + legacyEndpoints, err := m.EndpointService.Endpoints() + if err != nil { + return err + } + + for _, endpoint := range legacyEndpoints { + endpoint.Tags = []string{} + + err = m.EndpointService.UpdateEndpoint(endpoint.ID, &endpoint) + if err != nil { + return err + } + } + + return nil +} + +func (m *Migrator) updateEndpointGroupsToVersion12() error { + legacyEndpointGroups, err := m.EndpointGroupService.EndpointGroups() + if err != nil { + return err + } + + for _, group := range legacyEndpointGroups { + group.Tags = []string{} + + err = m.EndpointGroupService.UpdateEndpointGroup(group.ID, &group) + if err != nil { + return err + } + } + + return nil +} diff --git a/api/bolt/migrator.go b/api/bolt/migrator.go index 48242adaf..8f914a7b4 100644 --- a/api/bolt/migrator.go +++ b/api/bolt/migrator.go @@ -6,6 +6,7 @@ import "github.com/portainer/portainer" type Migrator struct { UserService *UserService EndpointService *EndpointService + EndpointGroupService *EndpointGroupService ResourceControlService *ResourceControlService SettingsService *SettingsService VersionService *VersionService @@ -18,6 +19,7 @@ func NewMigrator(store *Store, version int) *Migrator { return &Migrator{ UserService: store.UserService, EndpointService: store.EndpointService, + EndpointGroupService: store.EndpointGroupService, ResourceControlService: store.ResourceControlService, SettingsService: store.SettingsService, VersionService: store.VersionService, @@ -120,6 +122,18 @@ func (m *Migrator) Migrate() error { } } + if m.CurrentDBVersion < 12 { + err := m.updateEndpointsToVersion12() + if err != nil { + return err + } + + err = m.updateEndpointGroupsToVersion12() + if err != nil { + return err + } + } + err := m.VersionService.StoreDBVersion(portainer.DBVersion) if err != nil { return err diff --git a/api/bolt/tag_service.go b/api/bolt/tag_service.go new file mode 100644 index 000000000..0c57ace8f --- /dev/null +++ b/api/bolt/tag_service.go @@ -0,0 +1,71 @@ +package bolt + +import ( + "github.com/portainer/portainer" + "github.com/portainer/portainer/bolt/internal" + + "github.com/boltdb/bolt" +) + +// TagService represents a service for managing tags. +type TagService struct { + store *Store +} + +// Tags return an array containing all the tags. +func (service *TagService) Tags() ([]portainer.Tag, error) { + var tags = make([]portainer.Tag, 0) + err := service.store.db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(tagBucketName)) + + cursor := bucket.Cursor() + for k, v := cursor.First(); k != nil; k, v = cursor.Next() { + var tag portainer.Tag + err := internal.UnmarshalTag(v, &tag) + if err != nil { + return err + } + tags = append(tags, tag) + } + + return nil + }) + if err != nil { + return nil, err + } + + return tags, nil +} + +// CreateTag creates a new tag. +func (service *TagService) CreateTag(tag *portainer.Tag) error { + return service.store.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(tagBucketName)) + + id, _ := bucket.NextSequence() + tag.ID = portainer.TagID(id) + + data, err := internal.MarshalTag(tag) + if err != nil { + return err + } + + err = bucket.Put(internal.Itob(int(tag.ID)), data) + if err != nil { + return err + } + return nil + }) +} + +// DeleteTag deletes a tag. +func (service *TagService) DeleteTag(ID portainer.TagID) error { + return service.store.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(tagBucketName)) + err := bucket.Delete(internal.Itob(int(ID))) + if err != nil { + return err + } + return nil + }) +} diff --git a/api/cmd/portainer/main.go b/api/cmd/portainer/main.go index 0c13e118f..4f37e7846 100644 --- a/api/cmd/portainer/main.go +++ b/api/cmd/portainer/main.go @@ -236,6 +236,7 @@ func createTLSSecuredEndpoint(flags *portainer.CLIFlags, endpointService portain AuthorizedUsers: []portainer.UserID{}, AuthorizedTeams: []portainer.TeamID{}, Extensions: []portainer.EndpointExtension{}, + Tags: []string{}, } if strings.HasPrefix(endpoint.URL, "tcp://") { @@ -274,6 +275,7 @@ func createUnsecuredEndpoint(endpointURL string, endpointService portainer.Endpo AuthorizedUsers: []portainer.UserID{}, AuthorizedTeams: []portainer.TeamID{}, Extensions: []portainer.EndpointExtension{}, + Tags: []string{}, } return endpointService.CreateEndpoint(endpoint) @@ -401,6 +403,7 @@ func main() { RegistryService: store.RegistryService, DockerHubService: store.DockerHubService, StackService: store.StackService, + TagService: store.TagService, SwarmStackManager: swarmStackManager, ComposeStackManager: composeStackManager, CryptoService: cryptoService, diff --git a/api/errors.go b/api/errors.go index c8f534f2c..4c8823891 100644 --- a/api/errors.go +++ b/api/errors.go @@ -68,6 +68,11 @@ const ( ErrStackNotExternal = Error("Not an external stack") ) +// Tag errors +const ( + ErrTagAlreadyExists = Error("A tag already exists with this name") +) + // Endpoint extensions error const ( ErrEndpointExtensionNotSupported = Error("This extension is not supported") diff --git a/api/http/handler/endpointgroups/endpointgroup_create.go b/api/http/handler/endpointgroups/endpointgroup_create.go index 834122f08..e2d7bd0c0 100644 --- a/api/http/handler/endpointgroups/endpointgroup_create.go +++ b/api/http/handler/endpointgroups/endpointgroup_create.go @@ -13,14 +13,17 @@ import ( type endpointGroupCreatePayload struct { Name string Description string - Labels []portainer.Pair AssociatedEndpoints []portainer.EndpointID + Tags []string } func (payload *endpointGroupCreatePayload) Validate(r *http.Request) error { if govalidator.IsNull(payload.Name) { return portainer.Error("Invalid endpoint group name") } + if payload.Tags == nil { + payload.Tags = []string{} + } return nil } @@ -35,9 +38,9 @@ func (handler *Handler) endpointGroupCreate(w http.ResponseWriter, r *http.Reque endpointGroup := &portainer.EndpointGroup{ Name: payload.Name, Description: payload.Description, - Labels: payload.Labels, AuthorizedUsers: []portainer.UserID{}, AuthorizedTeams: []portainer.TeamID{}, + Tags: payload.Tags, } err = handler.EndpointGroupService.CreateEndpointGroup(endpointGroup) diff --git a/api/http/handler/endpointgroups/endpointgroup_update.go b/api/http/handler/endpointgroups/endpointgroup_update.go index 2f23c0f66..237e5d9a6 100644 --- a/api/http/handler/endpointgroups/endpointgroup_update.go +++ b/api/http/handler/endpointgroups/endpointgroup_update.go @@ -12,8 +12,8 @@ import ( type endpointGroupUpdatePayload struct { Name string Description string - Labels []portainer.Pair AssociatedEndpoints []portainer.EndpointID + Tags []string } func (payload *endpointGroupUpdatePayload) Validate(r *http.Request) error { @@ -48,7 +48,9 @@ func (handler *Handler) endpointGroupUpdate(w http.ResponseWriter, r *http.Reque endpointGroup.Description = payload.Description } - endpointGroup.Labels = payload.Labels + if payload.Tags != nil { + endpointGroup.Tags = payload.Tags + } err = handler.EndpointGroupService.UpdateEndpointGroup(endpointGroup.ID, endpointGroup) if err != nil { diff --git a/api/http/handler/endpoints/endpoint_create.go b/api/http/handler/endpoints/endpoint_create.go index 38d2cfd47..7827068cb 100644 --- a/api/http/handler/endpoints/endpoint_create.go +++ b/api/http/handler/endpoints/endpoint_create.go @@ -28,6 +28,7 @@ type endpointCreatePayload struct { AzureApplicationID string AzureTenantID string AzureAuthenticationKey string + Tags []string } func (payload *endpointCreatePayload) Validate(r *http.Request) error { @@ -49,6 +50,13 @@ func (payload *endpointCreatePayload) Validate(r *http.Request) error { } payload.GroupID = groupID + var tags []string + err = request.RetrieveMultiPartFormJSONValue(r, "Tags", &tags, true) + if err != nil { + return portainer.Error("Invalid Tags parameter") + } + payload.Tags = tags + useTLS, _ := request.RetrieveBooleanMultiPartFormValue(r, "TLS", true) payload.TLS = useTLS @@ -168,6 +176,7 @@ func (handler *Handler) createAzureEndpoint(payload *endpointCreatePayload) (*po AuthorizedTeams: []portainer.TeamID{}, Extensions: []portainer.EndpointExtension{}, AzureCredentials: credentials, + Tags: payload.Tags, } err = handler.EndpointService.CreateEndpoint(endpoint) @@ -203,6 +212,7 @@ func (handler *Handler) createUnsecuredEndpoint(payload *endpointCreatePayload) AuthorizedUsers: []portainer.UserID{}, AuthorizedTeams: []portainer.TeamID{}, Extensions: []portainer.EndpointExtension{}, + Tags: payload.Tags, } err := handler.EndpointService.CreateEndpoint(endpoint) @@ -242,6 +252,7 @@ func (handler *Handler) createTLSSecuredEndpoint(payload *endpointCreatePayload) AuthorizedUsers: []portainer.UserID{}, AuthorizedTeams: []portainer.TeamID{}, Extensions: []portainer.EndpointExtension{}, + Tags: payload.Tags, } err = handler.EndpointService.CreateEndpoint(endpoint) diff --git a/api/http/handler/endpoints/endpoint_update.go b/api/http/handler/endpoints/endpoint_update.go index a65e9872b..7532c0450 100644 --- a/api/http/handler/endpoints/endpoint_update.go +++ b/api/http/handler/endpoints/endpoint_update.go @@ -22,6 +22,7 @@ type endpointUpdatePayload struct { AzureApplicationID string AzureTenantID string AzureAuthenticationKey string + Tags []string } func (payload *endpointUpdatePayload) Validate(r *http.Request) error { @@ -68,6 +69,10 @@ func (handler *Handler) endpointUpdate(w http.ResponseWriter, r *http.Request) * endpoint.GroupID = portainer.EndpointGroupID(payload.GroupID) } + if payload.Tags != nil { + endpoint.Tags = payload.Tags + } + if endpoint.Type == portainer.AzureEnvironment { credentials := endpoint.AzureCredentials if payload.AzureApplicationID != "" { diff --git a/api/http/handler/handler.go b/api/http/handler/handler.go index 84d01ddc5..dd15e1212 100644 --- a/api/http/handler/handler.go +++ b/api/http/handler/handler.go @@ -15,6 +15,7 @@ import ( "github.com/portainer/portainer/http/handler/settings" "github.com/portainer/portainer/http/handler/stacks" "github.com/portainer/portainer/http/handler/status" + "github.com/portainer/portainer/http/handler/tags" "github.com/portainer/portainer/http/handler/teammemberships" "github.com/portainer/portainer/http/handler/teams" "github.com/portainer/portainer/http/handler/templates" @@ -37,16 +38,13 @@ type Handler struct { SettingsHandler *settings.Handler StackHandler *stacks.Handler StatusHandler *status.Handler + TagHandler *tags.Handler TeamMembershipHandler *teammemberships.Handler TeamHandler *teams.Handler TemplatesHandler *templates.Handler UploadHandler *upload.Handler UserHandler *users.Handler WebSocketHandler *websocket.Handler - - // StoridgeHandler *extensions.StoridgeHandler - // AzureHandler *azure.Handler - // DockerHandler *docker.Handler } // ServeHTTP delegates a request to the appropriate subhandler. @@ -79,6 +77,8 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { http.StripPrefix("/api", h.StackHandler).ServeHTTP(w, r) case strings.HasPrefix(r.URL.Path, "/api/status"): http.StripPrefix("/api", h.StatusHandler).ServeHTTP(w, r) + case strings.HasPrefix(r.URL.Path, "/api/tags"): + http.StripPrefix("/api", h.TagHandler).ServeHTTP(w, r) case strings.HasPrefix(r.URL.Path, "/api/templates"): http.StripPrefix("/api", h.TemplatesHandler).ServeHTTP(w, r) case strings.HasPrefix(r.URL.Path, "/api/upload"): diff --git a/api/http/handler/tags/handler.go b/api/http/handler/tags/handler.go new file mode 100644 index 000000000..a700f7c3e --- /dev/null +++ b/api/http/handler/tags/handler.go @@ -0,0 +1,31 @@ +package tags + +import ( + "net/http" + + "github.com/gorilla/mux" + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/security" +) + +// Handler is the HTTP handler used to handle tag operations. +type Handler struct { + *mux.Router + TagService portainer.TagService +} + +// NewHandler creates a handler to manage tag operations. +func NewHandler(bouncer *security.RequestBouncer) *Handler { + h := &Handler{ + Router: mux.NewRouter(), + } + h.Handle("/tags", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.tagCreate))).Methods(http.MethodPost) + h.Handle("/tags", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.tagList))).Methods(http.MethodGet) + h.Handle("/tags/{id}", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.tagDelete))).Methods(http.MethodDelete) + + return h +} diff --git a/api/http/handler/tags/tag_create.go b/api/http/handler/tags/tag_create.go new file mode 100644 index 000000000..f75c050b9 --- /dev/null +++ b/api/http/handler/tags/tag_create.go @@ -0,0 +1,53 @@ +package tags + +import ( + "net/http" + + "github.com/asaskevich/govalidator" + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" +) + +type tagCreatePayload struct { + Name string +} + +func (payload *tagCreatePayload) Validate(r *http.Request) error { + if govalidator.IsNull(payload.Name) { + return portainer.Error("Invalid tag name") + } + return nil +} + +// POST request on /api/tags +func (handler *Handler) tagCreate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + var payload tagCreatePayload + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + tags, err := handler.TagService.Tags() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve tags from the database", err} + } + + for _, tag := range tags { + if tag.Name == payload.Name { + return &httperror.HandlerError{http.StatusConflict, "This name is already associated to a tag", portainer.ErrTagAlreadyExists} + } + } + + tag := &portainer.Tag{ + Name: payload.Name, + } + + err = handler.TagService.CreateTag(tag) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the tag inside the database", err} + } + + return response.JSON(w, tag) +} diff --git a/api/http/handler/tags/tag_delete.go b/api/http/handler/tags/tag_delete.go new file mode 100644 index 000000000..b1b4fe867 --- /dev/null +++ b/api/http/handler/tags/tag_delete.go @@ -0,0 +1,25 @@ +package tags + +import ( + "net/http" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" +) + +// DELETE request on /api/tags/:name +func (handler *Handler) tagDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + id, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid tag identifier route variable", err} + } + + err = handler.TagService.DeleteTag(portainer.TagID(id)) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove the tag from the database", err} + } + + return response.Empty(w) +} diff --git a/api/http/handler/tags/tag_list.go b/api/http/handler/tags/tag_list.go new file mode 100644 index 000000000..b572add68 --- /dev/null +++ b/api/http/handler/tags/tag_list.go @@ -0,0 +1,18 @@ +package tags + +import ( + "net/http" + + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/response" +) + +// GET request on /api/tags +func (handler *Handler) tagList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + tags, err := handler.TagService.Tags() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve tags from the database", err} + } + + return response.JSON(w, tags) +} diff --git a/api/http/server.go b/api/http/server.go index f0b1cdbda..9c476c74d 100644 --- a/api/http/server.go +++ b/api/http/server.go @@ -16,6 +16,7 @@ import ( "github.com/portainer/portainer/http/handler/settings" "github.com/portainer/portainer/http/handler/stacks" "github.com/portainer/portainer/http/handler/status" + "github.com/portainer/portainer/http/handler/tags" "github.com/portainer/portainer/http/handler/teammemberships" "github.com/portainer/portainer/http/handler/teams" "github.com/portainer/portainer/http/handler/templates" @@ -36,24 +37,25 @@ type Server struct { AuthDisabled bool EndpointManagement bool Status *portainer.Status - UserService portainer.UserService - TeamService portainer.TeamService - TeamMembershipService portainer.TeamMembershipService + ComposeStackManager portainer.ComposeStackManager + CryptoService portainer.CryptoService + SignatureService portainer.DigitalSignatureService + DockerHubService portainer.DockerHubService EndpointService portainer.EndpointService EndpointGroupService portainer.EndpointGroupService + FileService portainer.FileService + GitService portainer.GitService + JWTService portainer.JWTService + LDAPService portainer.LDAPService + RegistryService portainer.RegistryService ResourceControlService portainer.ResourceControlService SettingsService portainer.SettingsService - CryptoService portainer.CryptoService - JWTService portainer.JWTService - FileService portainer.FileService - RegistryService portainer.RegistryService - DockerHubService portainer.DockerHubService StackService portainer.StackService SwarmStackManager portainer.SwarmStackManager - ComposeStackManager portainer.ComposeStackManager - LDAPService portainer.LDAPService - GitService portainer.GitService - SignatureService portainer.DigitalSignatureService + TagService portainer.TagService + TeamService portainer.TeamService + TeamMembershipService portainer.TeamMembershipService + UserService portainer.UserService Handler *handler.Handler SSL bool SSLCert string @@ -126,6 +128,9 @@ func (server *Server) Start() error { stackHandler.RegistryService = server.RegistryService stackHandler.DockerHubService = server.DockerHubService + var tagHandler = tags.NewHandler(requestBouncer) + tagHandler.TagService = server.TagService + var teamHandler = teams.NewHandler(requestBouncer) teamHandler.TeamService = server.TeamService teamHandler.TeamMembershipService = server.TeamMembershipService @@ -164,6 +169,7 @@ func (server *Server) Start() error { SettingsHandler: settingsHandler, StatusHandler: statusHandler, StackHandler: stackHandler, + TagHandler: tagHandler, TeamHandler: teamHandler, TeamMembershipHandler: teamMembershipHandler, TemplatesHandler: templatesHandler, diff --git a/api/portainer.go b/api/portainer.go index 5053cfccd..bfe84a213 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -190,6 +190,7 @@ type ( AuthorizedTeams []TeamID `json:"AuthorizedTeams"` Extensions []EndpointExtension `json:"Extensions"` AzureCredentials AzureCredentials `json:"AzureCredentials,omitempty"` + Tags []string `json:"Tags"` // Deprecated fields // Deprecated in DBVersion == 4 @@ -217,7 +218,10 @@ type ( Description string `json:"Description"` AuthorizedUsers []UserID `json:"AuthorizedUsers"` AuthorizedTeams []TeamID `json:"AuthorizedTeams"` - Labels []Pair `json:"Labels"` + Tags []string `json:"Tags"` + + // Deprecated fields + Labels []Pair `json:"Labels"` } // EndpointExtension represents a extension associated to an endpoint. @@ -264,6 +268,15 @@ type ( AccessLevel ResourceAccessLevel `json:"AccessLevel"` } + // TagID represents a tag identifier. + TagID int + + // Tag represents a tag that can be associated to a resource. + Tag struct { + ID TagID + Name string `json:"Name"` + } + // ResourceAccessLevel represents the level of control associated to a resource. ResourceAccessLevel int @@ -390,6 +403,13 @@ type ( DeleteResourceControl(ID ResourceControlID) error } + // TagService represents a service for managing tag data. + TagService interface { + Tags() ([]Tag, error) + CreateTag(tag *Tag) error + DeleteTag(ID TagID) error + } + // CryptoService represents a service for encrypting/hashing data. CryptoService interface { Hash(data string) (string, error) @@ -463,7 +483,7 @@ const ( // APIVersion is the version number of the Portainer API. APIVersion = "1.17.1-dev" // DBVersion is the version number of the Portainer database. - DBVersion = 11 + DBVersion = 12 // DefaultTemplatesURL represents the default URL for the templates definitions. DefaultTemplatesURL = "https://raw.githubusercontent.com/portainer/templates/master/templates.json" // PortainerAgentHeader represents the name of the header available in any agent response diff --git a/api/swagger.yaml b/api/swagger.yaml index 13277649e..224b9ecf1 100644 --- a/api/swagger.yaml +++ b/api/swagger.yaml @@ -81,6 +81,8 @@ tags: description: "Manage Docker stacks" - name: "users" description: "Manage users" +- name: "tags" + description: "Manage tags" - name: "teams" description: "Manage teams" - name: "team_memberships" @@ -1958,6 +1960,99 @@ paths: schema: $ref: "#/definitions/GenericError" + /tags: + get: + tags: + - "tags" + summary: "List tags" + description: | + List tags. + **Access policy**: administrator + operationId: "TagList" + produces: + - "application/json" + parameters: [] + responses: + 200: + description: "Success" + schema: + $ref: "#/definitions/TagListResponse" + 500: + description: "Server error" + schema: + $ref: "#/definitions/GenericError" + post: + tags: + - "tags" + summary: "Create a new tag" + description: | + Create a new tag. + **Access policy**: administrator + operationId: "TagCreate" + consumes: + - "application/json" + produces: + - "application/json" + parameters: + - in: "body" + name: "body" + description: "Tag details" + required: true + schema: + $ref: "#/definitions/TagCreateRequest" + responses: + 200: + description: "Success" + schema: + $ref: "#/definitions/Tag" + 400: + description: "Invalid request" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Invalid request data format" + 409: + description: "Conflict" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "A tag with the specified name already exists" + 500: + description: "Server error" + schema: + $ref: "#/definitions/GenericError" + /tags/{id}: + delete: + tags: + - "tags" + summary: "Remove a tag" + description: | + Remove a tag. + **Access policy**: administrator + operationId: "TagDelete" + parameters: + - name: "id" + in: "path" + description: "Tag identifier" + required: true + type: "integer" + responses: + 204: + description: "Success" + 400: + description: "Invalid request" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Invalid request" + 500: + description: "Server error" + schema: + $ref: "#/definitions/GenericError" + /teams: get: tags: @@ -2411,6 +2506,17 @@ securityDefinitions: name: "Authorization" in: "header" definitions: + Tag: + type: "object" + properties: + Id: + type: "integer" + example: 1 + description: "Tag identifier" + Name: + type: "string" + example: "org/acme" + description: "Tag name" Team: type: "object" properties: @@ -3334,6 +3440,19 @@ definitions: type: "boolean" example: true description: "Is the password valid" + TagListResponse: + type: "array" + items: + $ref: "#/definitions/Tag" + TagCreateRequest: + type: "object" + required: + - "Name" + properties: + Name: + type: "string" + example: "org/acme" + description: "Name" TeamCreateRequest: type: "object" required: diff --git a/app/constants.js b/app/constants.js index a51ce323e..c4f911034 100644 --- a/app/constants.js +++ b/app/constants.js @@ -9,6 +9,7 @@ angular.module('portainer') .constant('API_ENDPOINT_STACKS', 'api/stacks') .constant('API_ENDPOINT_STATUS', 'api/status') .constant('API_ENDPOINT_USERS', 'api/users') +.constant('API_ENDPOINT_TAGS', 'api/tags') .constant('API_ENDPOINT_TEAMS', 'api/teams') .constant('API_ENDPOINT_TEAM_MEMBERSHIPS', 'api/team_memberships') .constant('API_ENDPOINT_TEMPLATES', 'api/templates') diff --git a/app/portainer/__module.js b/app/portainer/__module.js index 701a2bb8e..6109cc422 100644 --- a/app/portainer/__module.js +++ b/app/portainer/__module.js @@ -296,6 +296,17 @@ angular.module('portainer.app', []) } }; + var tags = { + name: 'portainer.tags', + url: '/tags', + views: { + 'content@': { + templateUrl: 'app/portainer/views/tags/tags.html', + controller: 'TagsController' + } + } + }; + var users = { name: 'portainer.users', url: '/users', @@ -366,6 +377,7 @@ angular.module('portainer.app', []) $stateRegistryProvider.register(stack); $stateRegistryProvider.register(stackCreation); $stateRegistryProvider.register(support); + $stateRegistryProvider.register(tags); $stateRegistryProvider.register(users); $stateRegistryProvider.register(user); $stateRegistryProvider.register(teams); diff --git a/app/portainer/components/datatables/tags-datatable/tagsDatatable.html b/app/portainer/components/datatables/tags-datatable/tagsDatatable.html new file mode 100644 index 000000000..29613f496 --- /dev/null +++ b/app/portainer/components/datatables/tags-datatable/tagsDatatable.html @@ -0,0 +1,84 @@ +
+ + +
+
+ {{ $ctrl.titleText }} +
+
+ + Search + +
+
+
+ +
+ +
+ + + + + + + + + + + + + + + + + +
+ + + + + + Name + + + +
+ + + + + {{ item.Name }} +
Loading...
No tag available.
+
+ +
+
+
diff --git a/app/portainer/components/datatables/tags-datatable/tagsDatatable.js b/app/portainer/components/datatables/tags-datatable/tagsDatatable.js new file mode 100644 index 000000000..cc33e7b7a --- /dev/null +++ b/app/portainer/components/datatables/tags-datatable/tagsDatatable.js @@ -0,0 +1,14 @@ +angular.module('portainer.app').component('tagsDatatable', { + templateUrl: 'app/portainer/components/datatables/tags-datatable/tagsDatatable.html', + controller: 'GenericDatatableController', + bindings: { + titleText: '@', + titleIcon: '@', + dataset: '<', + tableKey: '@', + orderBy: '@', + reverseOrder: '<', + showTextFilter: '<', + removeAction: '<' + } +}); diff --git a/app/portainer/components/forms/group-form/group-form.js b/app/portainer/components/forms/group-form/group-form.js index e72054a7b..a4bb71231 100644 --- a/app/portainer/components/forms/group-form/group-form.js +++ b/app/portainer/components/forms/group-form/group-form.js @@ -21,6 +21,7 @@ angular.module('portainer.app').component('groupForm', { bindings: { model: '=', availableEndpoints: '=', + availableTags: '<', associatedEndpoints: '=', addLabelAction: '<', removeLabelAction: '<', diff --git a/app/portainer/components/forms/group-form/groupForm.html b/app/portainer/components/forms/group-form/groupForm.html index 0555c40e7..4a2d64331 100644 --- a/app/portainer/components/forms/group-form/groupForm.html +++ b/app/portainer/components/forms/group-form/groupForm.html @@ -22,33 +22,17 @@
- -
-
- - - add label - -
- -
-
-
- name - -
-
- value - -
- -
-
- +
+ Metadata
- + +
+ +
+
diff --git a/app/portainer/components/tag-selector/tag-selector.js b/app/portainer/components/tag-selector/tag-selector.js new file mode 100644 index 000000000..46b397d41 --- /dev/null +++ b/app/portainer/components/tag-selector/tag-selector.js @@ -0,0 +1,8 @@ +angular.module('portainer.app').component('tagSelector', { + templateUrl: 'app/portainer/components/tag-selector/tagSelector.html', + controller: 'TagSelectorController', + bindings: { + tags: '<', + model: '=' + } +}); diff --git a/app/portainer/components/tag-selector/tagSelector.html b/app/portainer/components/tag-selector/tagSelector.html new file mode 100644 index 000000000..be92e1aa4 --- /dev/null +++ b/app/portainer/components/tag-selector/tagSelector.html @@ -0,0 +1,38 @@ +
+ +
+ + {{ tag }} + + + + +
+
+
+ +
+ +
+
+ + No tags available. + +
+
+
+ + No tags matching your filter. + +
diff --git a/app/portainer/components/tag-selector/tagSelectorController.js b/app/portainer/components/tag-selector/tagSelectorController.js new file mode 100644 index 000000000..b7c18c2ca --- /dev/null +++ b/app/portainer/components/tag-selector/tagSelectorController.js @@ -0,0 +1,32 @@ +angular.module('portainer.app') +.controller('TagSelectorController', function () { + + var ctrl = this; + + this.$onChanges = function(changes) { + if(angular.isDefined(changes.tags.currentValue)) { + this.tags = _.difference(changes.tags.currentValue, this.model); + } + }; + + this.state = { + selectedValue: '', + noResult: false + }; + + this.selectTag = function($item, $model, $label) { + this.state.selectedValue = ''; + this.model.push($item); + this.tags = _.remove(this.tags, function(item) { + return item !== $item; + }); + }; + + this.removeTag = function(tag) { + var idx = this.model.indexOf(tag); + if (idx > -1) { + this.model.splice(idx, 1); + this.tags.push(tag); + } + }; +}); diff --git a/app/portainer/models/group.js b/app/portainer/models/group.js index bb8cf0b78..b16d825f7 100644 --- a/app/portainer/models/group.js +++ b/app/portainer/models/group.js @@ -1,14 +1,14 @@ function EndpointGroupDefaultModel() { this.Name = ''; this.Description = ''; - this.Labels = []; + this.Tags = []; } function EndpointGroupModel(data) { this.Id = data.Id; this.Name = data.Name; this.Description = data.Description; - this.Labels = data.Labels; + this.Tags = data.Tags; this.AuthorizedUsers = data.AuthorizedUsers; this.AuthorizedTeams = data.AuthorizedTeams; } @@ -16,7 +16,7 @@ function EndpointGroupModel(data) { function EndpointGroupCreateRequest(model, endpoints) { this.Name = model.Name; this.Description = model.Description; - this.Labels = model.Labels; + this.Tags = model.Tags; this.AssociatedEndpoints = endpoints; } @@ -24,6 +24,6 @@ function EndpointGroupUpdateRequest(model, endpoints) { this.id = model.Id; this.Name = model.Name; this.Description = model.Description; - this.Labels = model.Labels; + this.Tags = model.Tags; this.AssociatedEndpoints = endpoints; } diff --git a/app/portainer/models/tag.js b/app/portainer/models/tag.js new file mode 100644 index 000000000..dfefaff61 --- /dev/null +++ b/app/portainer/models/tag.js @@ -0,0 +1,4 @@ +function TagViewModel(data) { + this.Id = data.ID; + this.Name = data.Name; +} diff --git a/app/portainer/rest/tag.js b/app/portainer/rest/tag.js new file mode 100644 index 000000000..e3e656036 --- /dev/null +++ b/app/portainer/rest/tag.js @@ -0,0 +1,9 @@ +angular.module('portainer.app') +.factory('Tags', ['$resource', 'API_ENDPOINT_TAGS', function TagsFactory($resource, API_ENDPOINT_TAGS) { + 'use strict'; + return $resource(API_ENDPOINT_TAGS + '/:id', {}, { + create: { method: 'POST' }, + query: { method: 'GET', isArray: true }, + remove: { method: 'DELETE', params: { id: '@id'} } + }); +}]); diff --git a/app/portainer/services/api/endpointService.js b/app/portainer/services/api/endpointService.js index 8f1eb5f4d..e7dd9bb97 100644 --- a/app/portainer/services/api/endpointService.js +++ b/app/portainer/services/api/endpointService.js @@ -57,7 +57,7 @@ function EndpointServiceFactory($q, Endpoints, FileUploadService) { service.createLocalEndpoint = function() { var deferred = $q.defer(); - FileUploadService.createEndpoint('local', 1, 'unix:///var/run/docker.sock', '', 1, false) + FileUploadService.createEndpoint('local', 1, 'unix:///var/run/docker.sock', '', 1, [], false) .then(function success(response) { deferred.resolve(response.data); }) @@ -68,10 +68,10 @@ function EndpointServiceFactory($q, Endpoints, FileUploadService) { return deferred.promise; }; - service.createRemoteEndpoint = function(name, type, URL, PublicURL, groupID, TLS, TLSSkipVerify, TLSSkipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile) { + service.createRemoteEndpoint = function(name, type, URL, PublicURL, groupID, tags, TLS, TLSSkipVerify, TLSSkipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile) { var deferred = $q.defer(); - FileUploadService.createEndpoint(name, type, 'tcp://' + URL, PublicURL, groupID, TLS, TLSSkipVerify, TLSSkipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile) + FileUploadService.createEndpoint(name, type, 'tcp://' + URL, PublicURL, groupID, tags, TLS, TLSSkipVerify, TLSSkipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile) .then(function success(response) { deferred.resolve(response.data); }) @@ -82,10 +82,10 @@ function EndpointServiceFactory($q, Endpoints, FileUploadService) { return deferred.promise; }; - service.createAzureEndpoint = function(name, applicationId, tenantId, authenticationKey) { + service.createAzureEndpoint = function(name, applicationId, tenantId, authenticationKey, groupId, tags) { var deferred = $q.defer(); - FileUploadService.createAzureEndpoint(name, applicationId, tenantId, authenticationKey) + FileUploadService.createAzureEndpoint(name, applicationId, tenantId, authenticationKey, groupId, tags) .then(function success(response) { deferred.resolve(response.data); }) diff --git a/app/portainer/services/api/tagService.js b/app/portainer/services/api/tagService.js new file mode 100644 index 000000000..a73b9f932 --- /dev/null +++ b/app/portainer/services/api/tagService.js @@ -0,0 +1,49 @@ +angular.module('portainer.app') +.factory('TagService', ['$q', 'Tags', function TagServiceFactory($q, Tags) { + 'use strict'; + var service = {}; + + service.tags = function() { + var deferred = $q.defer(); + Tags.query().$promise + .then(function success(data) { + var tags = data.map(function (item) { + return new TagViewModel(item); + }); + deferred.resolve(tags); + }) + .catch(function error(err) { + deferred.reject({msg: 'Unable to retrieve tags', err: err}); + }); + return deferred.promise; + }; + + service.tagNames = function() { + var deferred = $q.defer(); + Tags.query().$promise + .then(function success(data) { + var tags = data.map(function (item) { + return item.Name; + }); + deferred.resolve(tags); + }) + .catch(function error(err) { + deferred.reject({msg: 'Unable to retrieve tags', err: err}); + }); + return deferred.promise; + }; + + service.createTag = function(name) { + var payload = { + Name: name + }; + + return Tags.create({}, payload).$promise; + }; + + service.deleteTag = function(id) { + return Tags.remove({id: id}).$promise; + }; + + return service; +}]); diff --git a/app/portainer/services/fileUpload.js b/app/portainer/services/fileUpload.js index d71f7f244..aef41e47a 100644 --- a/app/portainer/services/fileUpload.js +++ b/app/portainer/services/fileUpload.js @@ -52,7 +52,7 @@ angular.module('portainer.app') }); }; - service.createEndpoint = function(name, type, URL, PublicURL, groupID, TLS, TLSSkipVerify, TLSSkipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile) { + service.createEndpoint = function(name, type, URL, PublicURL, groupID, tags, TLS, TLSSkipVerify, TLSSkipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile) { return Upload.upload({ url: 'api/endpoints', data: { @@ -61,6 +61,7 @@ angular.module('portainer.app') URL: URL, PublicURL: PublicURL, GroupID: groupID, + Tags: Upload.json(tags), TLS: TLS, TLSSkipVerify: TLSSkipVerify, TLSSkipClientVerify: TLSSkipClientVerify, @@ -72,12 +73,14 @@ angular.module('portainer.app') }); }; - service.createAzureEndpoint = function(name, applicationId, tenantId, authenticationKey) { + service.createAzureEndpoint = function(name, applicationId, tenantId, authenticationKey, groupId, tags) { return Upload.upload({ url: 'api/endpoints', data: { Name: name, EndpointType: 3, + GroupID: groupID, + Tags: Upload.json(tags), AzureApplicationID: applicationId, AzureTenantID: tenantId, AzureAuthenticationKey: authenticationKey diff --git a/app/portainer/views/endpoints/create/createEndpointController.js b/app/portainer/views/endpoints/create/createEndpointController.js index 3418f251a..6453902e9 100644 --- a/app/portainer/views/endpoints/create/createEndpointController.js +++ b/app/portainer/views/endpoints/create/createEndpointController.js @@ -1,6 +1,6 @@ angular.module('portainer.app') -.controller('CreateEndpointController', ['$scope', '$state', '$filter', 'EndpointService', 'GroupService', 'Notifications', -function ($scope, $state, $filter, EndpointService, GroupService, Notifications) { +.controller('CreateEndpointController', ['$q', '$scope', '$state', '$filter', 'EndpointService', 'GroupService', 'TagService', 'Notifications', +function ($q, $scope, $state, $filter, EndpointService, GroupService, TagService, Notifications) { $scope.state = { EnvironmentType: 'docker', @@ -15,7 +15,8 @@ function ($scope, $state, $filter, EndpointService, GroupService, Notifications) SecurityFormData: new EndpointSecurityFormData(), AzureApplicationId: '', AzureTenantId: '', - AzureAuthenticationKey: '' + AzureAuthenticationKey: '', + Tags: [] }; $scope.addDockerEndpoint = function() { @@ -23,6 +24,7 @@ function ($scope, $state, $filter, EndpointService, GroupService, Notifications) var URL = $filter('stripprotocol')($scope.formValues.URL); var publicURL = $scope.formValues.PublicURL === '' ? URL.split(':')[0] : $scope.formValues.PublicURL; var groupId = $scope.formValues.GroupId; + var tags = $scope.formValues.Tags; var securityData = $scope.formValues.SecurityFormData; var TLS = securityData.TLS; @@ -33,7 +35,7 @@ function ($scope, $state, $filter, EndpointService, GroupService, Notifications) var TLSCertFile = TLSSkipClientVerify ? null : securityData.TLSCert; var TLSKeyFile = TLSSkipClientVerify ? null : securityData.TLSKey; - addEndpoint(name, 1, URL, publicURL, groupId, TLS, TLSSkipVerify, TLSSkipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile); + addEndpoint(name, 1, URL, publicURL, groupId, tags, TLS, TLSSkipVerify, TLSSkipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile); }; $scope.addAgentEndpoint = function() { @@ -41,8 +43,9 @@ function ($scope, $state, $filter, EndpointService, GroupService, Notifications) var URL = $filter('stripprotocol')($scope.formValues.URL); var publicURL = $scope.formValues.PublicURL === '' ? URL.split(':')[0] : $scope.formValues.PublicURL; var groupId = $scope.formValues.GroupId; + var tags = $scope.formValues.Tags; - addEndpoint(name, 2, URL, publicURL, groupId, true, true, true, null, null, null); + addEndpoint(name, 2, URL, publicURL, groupId, tags, true, true, true, null, null, null); }; $scope.addAzureEndpoint = function() { @@ -50,15 +53,17 @@ function ($scope, $state, $filter, EndpointService, GroupService, Notifications) var applicationId = $scope.formValues.AzureApplicationId; var tenantId = $scope.formValues.AzureTenantId; var authenticationKey = $scope.formValues.AzureAuthenticationKey; + var groupId = $scope.formValues.GroupId; + var tags = $scope.formValues.Tags; - createAzureEndpoint(name, applicationId, tenantId, authenticationKey); + createAzureEndpoint(name, applicationId, tenantId, authenticationKey, groupId, tags); }; - function createAzureEndpoint(name, applicationId, tenantId, authenticationKey) { + function createAzureEndpoint(name, applicationId, tenantId, authenticationKey, groupId, tags) { var endpoint; $scope.state.actionInProgress = true; - EndpointService.createAzureEndpoint(name, applicationId, tenantId, authenticationKey) + EndpointService.createAzureEndpoint(name, applicationId, tenantId, authenticationKey, groupId, tags) .then(function success() { Notifications.success('Endpoint created', name); $state.go('portainer.endpoints', {}, {reload: true}); @@ -71,9 +76,9 @@ function ($scope, $state, $filter, EndpointService, GroupService, Notifications) }); } - function addEndpoint(name, type, URL, PublicURL, groupId, TLS, TLSSkipVerify, TLSSkipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile) { + function addEndpoint(name, type, URL, PublicURL, groupId, tags, TLS, TLSSkipVerify, TLSSkipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile) { $scope.state.actionInProgress = true; - EndpointService.createRemoteEndpoint(name, type, URL, PublicURL, groupId, TLS, TLSSkipVerify, TLSSkipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile) + EndpointService.createRemoteEndpoint(name, type, URL, PublicURL, groupId, tags, TLS, TLSSkipVerify, TLSSkipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile) .then(function success() { Notifications.success('Endpoint created', name); $state.go('portainer.endpoints', {}, {reload: true}); @@ -87,9 +92,13 @@ function ($scope, $state, $filter, EndpointService, GroupService, Notifications) } function initView() { - GroupService.groups() + $q.all({ + groups: GroupService.groups(), + tags: TagService.tagNames() + }) .then(function success(data) { - $scope.groups = data; + $scope.groups = data.groups; + $scope.availableTags = data.tags; }) .catch(function error(err) { Notifications.error('Failure', err, 'Unable to load groups'); diff --git a/app/portainer/views/endpoints/create/createendpoint.html b/app/portainer/views/endpoints/create/createendpoint.html index a4053d8f1..cce0f67c7 100644 --- a/app/portainer/views/endpoints/create/createendpoint.html +++ b/app/portainer/views/endpoints/create/createendpoint.html @@ -192,6 +192,12 @@
+ + + +
+ Metadata +
- - - + +
+ +
+ +
+ Actions +
diff --git a/app/portainer/views/endpoints/edit/endpoint.html b/app/portainer/views/endpoints/edit/endpoint.html index c225d34fa..6a1349d35 100644 --- a/app/portainer/views/endpoints/edit/endpoint.html +++ b/app/portainer/views/endpoints/edit/endpoint.html @@ -49,7 +49,7 @@ >
- Grouping + Metadata
@@ -61,6 +61,14 @@
+ +
+ +
+
diff --git a/app/portainer/views/endpoints/edit/endpointController.js b/app/portainer/views/endpoints/edit/endpointController.js index 1d7cd8c3d..378924ccf 100644 --- a/app/portainer/views/endpoints/edit/endpointController.js +++ b/app/portainer/views/endpoints/edit/endpointController.js @@ -1,6 +1,6 @@ angular.module('portainer.app') -.controller('EndpointController', ['$q', '$scope', '$state', '$transition$', '$filter', 'EndpointService', 'GroupService', 'EndpointProvider', 'Notifications', -function ($q, $scope, $state, $transition$, $filter, EndpointService, GroupService, EndpointProvider, Notifications) { +.controller('EndpointController', ['$q', '$scope', '$state', '$transition$', '$filter', 'EndpointService', 'GroupService', 'TagService', 'EndpointProvider', 'Notifications', +function ($q, $scope, $state, $transition$, $filter, EndpointService, GroupService, TagService, EndpointProvider, Notifications) { if (!$scope.applicationState.application.endpointManagement) { $state.go('portainer.endpoints'); @@ -27,6 +27,7 @@ function ($q, $scope, $state, $transition$, $filter, EndpointService, GroupServi Name: endpoint.Name, PublicURL: endpoint.PublicURL, GroupID: endpoint.GroupId, + Tags: endpoint.Tags, TLS: TLS, TLSSkipVerify: TLSSkipVerify, TLSSkipClientVerify: TLSSkipClientVerify, @@ -61,7 +62,8 @@ function ($q, $scope, $state, $transition$, $filter, EndpointService, GroupServi function initView() { $q.all({ endpoint: EndpointService.endpoint($transition$.params().id), - groups: GroupService.groups() + groups: GroupService.groups(), + tags: TagService.tagNames() }) .then(function success(data) { var endpoint = data.endpoint; @@ -73,6 +75,7 @@ function ($q, $scope, $state, $transition$, $filter, EndpointService, GroupServi endpoint.URL = $filter('stripprotocol')(endpoint.URL); $scope.endpoint = endpoint; $scope.groups = data.groups; + $scope.availableTags = data.tags; }) .catch(function error(err) { Notifications.error('Failure', err, 'Unable to retrieve endpoint details'); diff --git a/app/portainer/views/groups/create/createGroupController.js b/app/portainer/views/groups/create/createGroupController.js index 1a14510fa..9fcf47c84 100644 --- a/app/portainer/views/groups/create/createGroupController.js +++ b/app/portainer/views/groups/create/createGroupController.js @@ -1,19 +1,11 @@ angular.module('portainer.app') -.controller('CreateGroupController', ['$scope', '$state', 'GroupService', 'EndpointService', 'Notifications', -function ($scope, $state, GroupService, EndpointService, Notifications) { +.controller('CreateGroupController', ['$q', '$scope', '$state', 'GroupService', 'EndpointService', 'TagService', 'Notifications', +function ($q, $scope, $state, GroupService, EndpointService, TagService, Notifications) { $scope.state = { actionInProgress: false }; - $scope.addLabel = function() { - $scope.model.Labels.push({ name: '', value: '' }); - }; - - $scope.removeLabel = function(index) { - $scope.model.Labels.splice(index, 1); - }; - $scope.create = function() { var model = $scope.model; @@ -40,10 +32,14 @@ function ($scope, $state, GroupService, EndpointService, Notifications) { function initView() { $scope.model = new EndpointGroupDefaultModel(); - EndpointService.endpointsByGroup(1) + $q.all({ + endpoints: EndpointService.endpointsByGroup(1), + tags: TagService.tagNames() + }) .then(function success(data) { - $scope.availableEndpoints = data; + $scope.availableEndpoints = data.endpoints; $scope.associatedEndpoints = []; + $scope.availableTags = data.tags; }) .catch(function error(err) { Notifications.error('Failure', err, 'Unable to retrieve endpoints'); diff --git a/app/portainer/views/groups/create/creategroup.html b/app/portainer/views/groups/create/creategroup.html index 8d307d40a..36334979c 100644 --- a/app/portainer/views/groups/create/creategroup.html +++ b/app/portainer/views/groups/create/creategroup.html @@ -12,6 +12,7 @@
{{ item.Status }} From f3ce5c25de611cb924fc5e2320968895eebb2ef4 Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Sun, 17 Jun 2018 19:57:22 +0300 Subject: [PATCH 20/37] refactor(api): use generic marshal/unmarshal functions in bolt package --- api/bolt/dockerhub_service.go | 4 +- api/bolt/endpoint_group_service.go | 8 +- api/bolt/endpoint_service.go | 8 +- api/bolt/internal/internal.go | 114 ++------------------------- api/bolt/migrate_dbversion1.go | 6 +- api/bolt/registry_service.go | 8 +- api/bolt/resource_control_service.go | 10 +-- api/bolt/settings_service.go | 4 +- api/bolt/stack_service.go | 10 +-- api/bolt/tag_service.go | 4 +- api/bolt/team_membership_service.go | 16 ++-- api/bolt/team_service.go | 10 +-- api/bolt/user_service.go | 12 +-- 13 files changed, 56 insertions(+), 158 deletions(-) diff --git a/api/bolt/dockerhub_service.go b/api/bolt/dockerhub_service.go index 34acd5594..d28aa6fb2 100644 --- a/api/bolt/dockerhub_service.go +++ b/api/bolt/dockerhub_service.go @@ -35,7 +35,7 @@ func (service *DockerHubService) DockerHub() (*portainer.DockerHub, error) { } var dockerhub portainer.DockerHub - err = internal.UnmarshalDockerHub(data, &dockerhub) + err = internal.UnmarshalObject(data, &dockerhub) if err != nil { return nil, err } @@ -47,7 +47,7 @@ func (service *DockerHubService) StoreDockerHub(dockerhub *portainer.DockerHub) return service.store.db.Update(func(tx *bolt.Tx) error { bucket := tx.Bucket([]byte(dockerhubBucketName)) - data, err := internal.MarshalDockerHub(dockerhub) + data, err := internal.MarshalObject(dockerhub) if err != nil { return err } diff --git a/api/bolt/endpoint_group_service.go b/api/bolt/endpoint_group_service.go index c52e95dac..9a539ee83 100644 --- a/api/bolt/endpoint_group_service.go +++ b/api/bolt/endpoint_group_service.go @@ -31,7 +31,7 @@ func (service *EndpointGroupService) EndpointGroup(ID portainer.EndpointGroupID) } var endpointGroup portainer.EndpointGroup - err = internal.UnmarshalEndpointGroup(data, &endpointGroup) + err = internal.UnmarshalObject(data, &endpointGroup) if err != nil { return nil, err } @@ -47,7 +47,7 @@ func (service *EndpointGroupService) EndpointGroups() ([]portainer.EndpointGroup cursor := bucket.Cursor() for k, v := cursor.First(); k != nil; k, v = cursor.Next() { var endpointGroup portainer.EndpointGroup - err := internal.UnmarshalEndpointGroup(v, &endpointGroup) + err := internal.UnmarshalObject(v, &endpointGroup) if err != nil { return err } @@ -71,7 +71,7 @@ func (service *EndpointGroupService) CreateEndpointGroup(endpointGroup *portaine id, _ := bucket.NextSequence() endpointGroup.ID = portainer.EndpointGroupID(id) - data, err := internal.MarshalEndpointGroup(endpointGroup) + data, err := internal.MarshalObject(endpointGroup) if err != nil { return err } @@ -86,7 +86,7 @@ func (service *EndpointGroupService) CreateEndpointGroup(endpointGroup *portaine // UpdateEndpointGroup updates an endpoint group. func (service *EndpointGroupService) UpdateEndpointGroup(ID portainer.EndpointGroupID, endpointGroup *portainer.EndpointGroup) error { - data, err := internal.MarshalEndpointGroup(endpointGroup) + data, err := internal.MarshalObject(endpointGroup) if err != nil { return err } diff --git a/api/bolt/endpoint_service.go b/api/bolt/endpoint_service.go index a92048d7a..e685a9b72 100644 --- a/api/bolt/endpoint_service.go +++ b/api/bolt/endpoint_service.go @@ -31,7 +31,7 @@ func (service *EndpointService) Endpoint(ID portainer.EndpointID) (*portainer.En } var endpoint portainer.Endpoint - err = internal.UnmarshalEndpoint(data, &endpoint) + err = internal.UnmarshalObject(data, &endpoint) if err != nil { return nil, err } @@ -47,7 +47,7 @@ func (service *EndpointService) Endpoints() ([]portainer.Endpoint, error) { cursor := bucket.Cursor() for k, v := cursor.First(); k != nil; k, v = cursor.Next() { var endpoint portainer.Endpoint - err := internal.UnmarshalEndpoint(v, &endpoint) + err := internal.UnmarshalObject(v, &endpoint) if err != nil { return err } @@ -107,7 +107,7 @@ func (service *EndpointService) CreateEndpoint(endpoint *portainer.Endpoint) err // UpdateEndpoint updates an endpoint. func (service *EndpointService) UpdateEndpoint(ID portainer.EndpointID, endpoint *portainer.Endpoint) error { - data, err := internal.MarshalEndpoint(endpoint) + data, err := internal.MarshalObject(endpoint) if err != nil { return err } @@ -135,7 +135,7 @@ func (service *EndpointService) DeleteEndpoint(ID portainer.EndpointID) error { } func marshalAndStoreEndpoint(endpoint *portainer.Endpoint, bucket *bolt.Bucket) error { - data, err := internal.MarshalEndpoint(endpoint) + data, err := internal.MarshalObject(endpoint) if err != nil { return err } diff --git a/api/bolt/internal/internal.go b/api/bolt/internal/internal.go index 7ed0d72be..4c45524b6 100644 --- a/api/bolt/internal/internal.go +++ b/api/bolt/internal/internal.go @@ -1,120 +1,18 @@ package internal import ( - "github.com/portainer/portainer" - "encoding/binary" "encoding/json" ) -// MarshalUser encodes a user to binary format. -func MarshalUser(user *portainer.User) ([]byte, error) { - return json.Marshal(user) +// MarshalObject encodes an object to binary format +func MarshalObject(object interface{}) ([]byte, error) { + return json.Marshal(object) } -// UnmarshalUser decodes a user from a binary data. -func UnmarshalUser(data []byte, user *portainer.User) error { - return json.Unmarshal(data, user) -} - -// MarshalTeam encodes a team to binary format. -func MarshalTeam(team *portainer.Team) ([]byte, error) { - return json.Marshal(team) -} - -// UnmarshalTeam decodes a team from a binary data. -func UnmarshalTeam(data []byte, team *portainer.Team) error { - return json.Unmarshal(data, team) -} - -// MarshalTeamMembership encodes a team membership to binary format. -func MarshalTeamMembership(membership *portainer.TeamMembership) ([]byte, error) { - return json.Marshal(membership) -} - -// UnmarshalTeamMembership decodes a team membership from a binary data. -func UnmarshalTeamMembership(data []byte, membership *portainer.TeamMembership) error { - return json.Unmarshal(data, membership) -} - -// MarshalEndpoint encodes an endpoint to binary format. -func MarshalEndpoint(endpoint *portainer.Endpoint) ([]byte, error) { - return json.Marshal(endpoint) -} - -// UnmarshalEndpoint decodes an endpoint from a binary data. -func UnmarshalEndpoint(data []byte, endpoint *portainer.Endpoint) error { - return json.Unmarshal(data, endpoint) -} - -// MarshalEndpointGroup encodes an endpoint group to binary format. -func MarshalEndpointGroup(group *portainer.EndpointGroup) ([]byte, error) { - return json.Marshal(group) -} - -// UnmarshalEndpointGroup decodes an endpoint group from a binary data. -func UnmarshalEndpointGroup(data []byte, group *portainer.EndpointGroup) error { - return json.Unmarshal(data, group) -} - -// MarshalStack encodes a stack to binary format. -func MarshalStack(stack *portainer.Stack) ([]byte, error) { - return json.Marshal(stack) -} - -// UnmarshalStack decodes a stack from a binary data. -func UnmarshalStack(data []byte, stack *portainer.Stack) error { - return json.Unmarshal(data, stack) -} - -// MarshalRegistry encodes a registry to binary format. -func MarshalRegistry(registry *portainer.Registry) ([]byte, error) { - return json.Marshal(registry) -} - -// UnmarshalRegistry decodes a registry from a binary data. -func UnmarshalRegistry(data []byte, registry *portainer.Registry) error { - return json.Unmarshal(data, registry) -} - -// MarshalResourceControl encodes a resource control object to binary format. -func MarshalResourceControl(rc *portainer.ResourceControl) ([]byte, error) { - return json.Marshal(rc) -} - -// UnmarshalResourceControl decodes a resource control object from a binary data. -func UnmarshalResourceControl(data []byte, rc *portainer.ResourceControl) error { - return json.Unmarshal(data, rc) -} - -// MarshalSettings encodes a settings object to binary format. -func MarshalSettings(settings *portainer.Settings) ([]byte, error) { - return json.Marshal(settings) -} - -// UnmarshalSettings decodes a settings object from a binary data. -func UnmarshalSettings(data []byte, settings *portainer.Settings) error { - return json.Unmarshal(data, settings) -} - -// MarshalDockerHub encodes a Dockerhub object to binary format. -func MarshalDockerHub(settings *portainer.DockerHub) ([]byte, error) { - return json.Marshal(settings) -} - -// UnmarshalDockerHub decodes a Dockerhub object from a binary data. -func UnmarshalDockerHub(data []byte, settings *portainer.DockerHub) error { - return json.Unmarshal(data, settings) -} - -// MarshalTag encodes a Tag object to binary format. -func MarshalTag(tag *portainer.Tag) ([]byte, error) { - return json.Marshal(tag) -} - -// UnmarshalTag decodes a Tag object from a binary data. -func UnmarshalTag(data []byte, tag *portainer.Tag) error { - return json.Unmarshal(data, tag) +// UnmarshalObject decodes an object from binary data +func UnmarshalObject(data []byte, object interface{}) error { + return json.Unmarshal(data, object) } // Itob returns an 8-byte big endian representation of v. diff --git a/api/bolt/migrate_dbversion1.go b/api/bolt/migrate_dbversion1.go index b34ba7867..0b255cd68 100644 --- a/api/bolt/migrate_dbversion1.go +++ b/api/bolt/migrate_dbversion1.go @@ -66,7 +66,7 @@ func (m *Migrator) retrieveLegacyResourceControls() ([]portainer.ResourceControl cursor := bucket.Cursor() for k, v := cursor.First(); k != nil; k, v = cursor.Next() { var resourceControl portainer.ResourceControl - err := internal.UnmarshalResourceControl(v, &resourceControl) + err := internal.UnmarshalObject(v, &resourceControl) if err != nil { return err } @@ -78,7 +78,7 @@ func (m *Migrator) retrieveLegacyResourceControls() ([]portainer.ResourceControl cursor = bucket.Cursor() for k, v := cursor.First(); k != nil; k, v = cursor.Next() { var resourceControl portainer.ResourceControl - err := internal.UnmarshalResourceControl(v, &resourceControl) + err := internal.UnmarshalObject(v, &resourceControl) if err != nil { return err } @@ -90,7 +90,7 @@ func (m *Migrator) retrieveLegacyResourceControls() ([]portainer.ResourceControl cursor = bucket.Cursor() for k, v := cursor.First(); k != nil; k, v = cursor.Next() { var resourceControl portainer.ResourceControl - err := internal.UnmarshalResourceControl(v, &resourceControl) + err := internal.UnmarshalObject(v, &resourceControl) if err != nil { return err } diff --git a/api/bolt/registry_service.go b/api/bolt/registry_service.go index 4c0c393ae..a7cabb313 100644 --- a/api/bolt/registry_service.go +++ b/api/bolt/registry_service.go @@ -31,7 +31,7 @@ func (service *RegistryService) Registry(ID portainer.RegistryID) (*portainer.Re } var registry portainer.Registry - err = internal.UnmarshalRegistry(data, ®istry) + err = internal.UnmarshalObject(data, ®istry) if err != nil { return nil, err } @@ -47,7 +47,7 @@ func (service *RegistryService) Registries() ([]portainer.Registry, error) { cursor := bucket.Cursor() for k, v := cursor.First(); k != nil; k, v = cursor.Next() { var registry portainer.Registry - err := internal.UnmarshalRegistry(v, ®istry) + err := internal.UnmarshalObject(v, ®istry) if err != nil { return err } @@ -71,7 +71,7 @@ func (service *RegistryService) CreateRegistry(registry *portainer.Registry) err id, _ := bucket.NextSequence() registry.ID = portainer.RegistryID(id) - data, err := internal.MarshalRegistry(registry) + data, err := internal.MarshalObject(registry) if err != nil { return err } @@ -86,7 +86,7 @@ func (service *RegistryService) CreateRegistry(registry *portainer.Registry) err // UpdateRegistry updates an registry. func (service *RegistryService) UpdateRegistry(ID portainer.RegistryID, registry *portainer.Registry) error { - data, err := internal.MarshalRegistry(registry) + data, err := internal.MarshalObject(registry) if err != nil { return err } diff --git a/api/bolt/resource_control_service.go b/api/bolt/resource_control_service.go index 2986d5add..cbd13fa4a 100644 --- a/api/bolt/resource_control_service.go +++ b/api/bolt/resource_control_service.go @@ -31,7 +31,7 @@ func (service *ResourceControlService) ResourceControl(ID portainer.ResourceCont } var resourceControl portainer.ResourceControl - err = internal.UnmarshalResourceControl(data, &resourceControl) + err = internal.UnmarshalObject(data, &resourceControl) if err != nil { return nil, err } @@ -48,7 +48,7 @@ func (service *ResourceControlService) ResourceControlByResourceID(resourceID st cursor := bucket.Cursor() for k, v := cursor.First(); k != nil; k, v = cursor.Next() { var rc portainer.ResourceControl - err := internal.UnmarshalResourceControl(v, &rc) + err := internal.UnmarshalObject(v, &rc) if err != nil { return err } @@ -82,7 +82,7 @@ func (service *ResourceControlService) ResourceControls() ([]portainer.ResourceC cursor := bucket.Cursor() for k, v := cursor.First(); k != nil; k, v = cursor.Next() { var resourceControl portainer.ResourceControl - err := internal.UnmarshalResourceControl(v, &resourceControl) + err := internal.UnmarshalObject(v, &resourceControl) if err != nil { return err } @@ -104,7 +104,7 @@ func (service *ResourceControlService) CreateResourceControl(resourceControl *po bucket := tx.Bucket([]byte(resourceControlBucketName)) id, _ := bucket.NextSequence() resourceControl.ID = portainer.ResourceControlID(id) - data, err := internal.MarshalResourceControl(resourceControl) + data, err := internal.MarshalObject(resourceControl) if err != nil { return err } @@ -119,7 +119,7 @@ func (service *ResourceControlService) CreateResourceControl(resourceControl *po // UpdateResourceControl saves a ResourceControl object. func (service *ResourceControlService) UpdateResourceControl(ID portainer.ResourceControlID, resourceControl *portainer.ResourceControl) error { - data, err := internal.MarshalResourceControl(resourceControl) + data, err := internal.MarshalObject(resourceControl) if err != nil { return err } diff --git a/api/bolt/settings_service.go b/api/bolt/settings_service.go index 9ea7cc2ab..2053feac9 100644 --- a/api/bolt/settings_service.go +++ b/api/bolt/settings_service.go @@ -35,7 +35,7 @@ func (service *SettingsService) Settings() (*portainer.Settings, error) { } var settings portainer.Settings - err = internal.UnmarshalSettings(data, &settings) + err = internal.UnmarshalObject(data, &settings) if err != nil { return nil, err } @@ -47,7 +47,7 @@ func (service *SettingsService) StoreSettings(settings *portainer.Settings) erro return service.store.db.Update(func(tx *bolt.Tx) error { bucket := tx.Bucket([]byte(settingsBucketName)) - data, err := internal.MarshalSettings(settings) + data, err := internal.MarshalObject(settings) if err != nil { return err } diff --git a/api/bolt/stack_service.go b/api/bolt/stack_service.go index 7fe023546..60230341b 100644 --- a/api/bolt/stack_service.go +++ b/api/bolt/stack_service.go @@ -31,7 +31,7 @@ func (service *StackService) Stack(ID portainer.StackID) (*portainer.Stack, erro } var stack portainer.Stack - err = internal.UnmarshalStack(data, &stack) + err = internal.UnmarshalObject(data, &stack) if err != nil { return nil, err } @@ -47,7 +47,7 @@ func (service *StackService) StackByName(name string) (*portainer.Stack, error) cursor := bucket.Cursor() for k, v := cursor.First(); k != nil; k, v = cursor.Next() { var t portainer.Stack - err := internal.UnmarshalStack(v, &t) + err := internal.UnmarshalObject(v, &t) if err != nil { return err } @@ -76,7 +76,7 @@ func (service *StackService) Stacks() ([]portainer.Stack, error) { cursor := bucket.Cursor() for k, v := cursor.First(); k != nil; k, v = cursor.Next() { var stack portainer.Stack - err := internal.UnmarshalStack(v, &stack) + err := internal.UnmarshalObject(v, &stack) if err != nil { return err } @@ -97,7 +97,7 @@ func (service *StackService) CreateStack(stack *portainer.Stack) error { return service.store.db.Update(func(tx *bolt.Tx) error { bucket := tx.Bucket([]byte(stackBucketName)) - data, err := internal.MarshalStack(stack) + data, err := internal.MarshalObject(stack) if err != nil { return err } @@ -112,7 +112,7 @@ func (service *StackService) CreateStack(stack *portainer.Stack) error { // UpdateStack updates an stack. func (service *StackService) UpdateStack(ID portainer.StackID, stack *portainer.Stack) error { - data, err := internal.MarshalStack(stack) + data, err := internal.MarshalObject(stack) if err != nil { return err } diff --git a/api/bolt/tag_service.go b/api/bolt/tag_service.go index 0c57ace8f..9ff0fb244 100644 --- a/api/bolt/tag_service.go +++ b/api/bolt/tag_service.go @@ -21,7 +21,7 @@ func (service *TagService) Tags() ([]portainer.Tag, error) { cursor := bucket.Cursor() for k, v := cursor.First(); k != nil; k, v = cursor.Next() { var tag portainer.Tag - err := internal.UnmarshalTag(v, &tag) + err := internal.UnmarshalObject(v, &tag) if err != nil { return err } @@ -45,7 +45,7 @@ func (service *TagService) CreateTag(tag *portainer.Tag) error { id, _ := bucket.NextSequence() tag.ID = portainer.TagID(id) - data, err := internal.MarshalTag(tag) + data, err := internal.MarshalObject(tag) if err != nil { return err } diff --git a/api/bolt/team_membership_service.go b/api/bolt/team_membership_service.go index da2b47266..b56a4bed7 100644 --- a/api/bolt/team_membership_service.go +++ b/api/bolt/team_membership_service.go @@ -31,7 +31,7 @@ func (service *TeamMembershipService) TeamMembership(ID portainer.TeamMembership } var membership portainer.TeamMembership - err = internal.UnmarshalTeamMembership(data, &membership) + err = internal.UnmarshalObject(data, &membership) if err != nil { return nil, err } @@ -47,7 +47,7 @@ func (service *TeamMembershipService) TeamMemberships() ([]portainer.TeamMembers cursor := bucket.Cursor() for k, v := cursor.First(); k != nil; k, v = cursor.Next() { var membership portainer.TeamMembership - err := internal.UnmarshalTeamMembership(v, &membership) + err := internal.UnmarshalObject(v, &membership) if err != nil { return err } @@ -72,7 +72,7 @@ func (service *TeamMembershipService) TeamMembershipsByUserID(userID portainer.U cursor := bucket.Cursor() for k, v := cursor.First(); k != nil; k, v = cursor.Next() { var membership portainer.TeamMembership - err := internal.UnmarshalTeamMembership(v, &membership) + err := internal.UnmarshalObject(v, &membership) if err != nil { return err } @@ -99,7 +99,7 @@ func (service *TeamMembershipService) TeamMembershipsByTeamID(teamID portainer.T cursor := bucket.Cursor() for k, v := cursor.First(); k != nil; k, v = cursor.Next() { var membership portainer.TeamMembership - err := internal.UnmarshalTeamMembership(v, &membership) + err := internal.UnmarshalObject(v, &membership) if err != nil { return err } @@ -119,7 +119,7 @@ func (service *TeamMembershipService) TeamMembershipsByTeamID(teamID portainer.T // UpdateTeamMembership saves a TeamMembership object. func (service *TeamMembershipService) UpdateTeamMembership(ID portainer.TeamMembershipID, membership *portainer.TeamMembership) error { - data, err := internal.MarshalTeamMembership(membership) + data, err := internal.MarshalObject(membership) if err != nil { return err } @@ -143,7 +143,7 @@ func (service *TeamMembershipService) CreateTeamMembership(membership *portainer id, _ := bucket.NextSequence() membership.ID = portainer.TeamMembershipID(id) - data, err := internal.MarshalTeamMembership(membership) + data, err := internal.MarshalObject(membership) if err != nil { return err } @@ -176,7 +176,7 @@ func (service *TeamMembershipService) DeleteTeamMembershipByUserID(userID portai cursor := bucket.Cursor() for k, v := cursor.First(); k != nil; k, v = cursor.Next() { var membership portainer.TeamMembership - err := internal.UnmarshalTeamMembership(v, &membership) + err := internal.UnmarshalObject(v, &membership) if err != nil { return err } @@ -200,7 +200,7 @@ func (service *TeamMembershipService) DeleteTeamMembershipByTeamID(teamID portai cursor := bucket.Cursor() for k, v := cursor.First(); k != nil; k, v = cursor.Next() { var membership portainer.TeamMembership - err := internal.UnmarshalTeamMembership(v, &membership) + err := internal.UnmarshalObject(v, &membership) if err != nil { return err } diff --git a/api/bolt/team_service.go b/api/bolt/team_service.go index 2830e7783..61ba23207 100644 --- a/api/bolt/team_service.go +++ b/api/bolt/team_service.go @@ -31,7 +31,7 @@ func (service *TeamService) Team(ID portainer.TeamID) (*portainer.Team, error) { } var team portainer.Team - err = internal.UnmarshalTeam(data, &team) + err = internal.UnmarshalObject(data, &team) if err != nil { return nil, err } @@ -47,7 +47,7 @@ func (service *TeamService) TeamByName(name string) (*portainer.Team, error) { cursor := bucket.Cursor() for k, v := cursor.First(); k != nil; k, v = cursor.Next() { var t portainer.Team - err := internal.UnmarshalTeam(v, &t) + err := internal.UnmarshalObject(v, &t) if err != nil { return err } @@ -76,7 +76,7 @@ func (service *TeamService) Teams() ([]portainer.Team, error) { cursor := bucket.Cursor() for k, v := cursor.First(); k != nil; k, v = cursor.Next() { var team portainer.Team - err := internal.UnmarshalTeam(v, &team) + err := internal.UnmarshalObject(v, &team) if err != nil { return err } @@ -94,7 +94,7 @@ func (service *TeamService) Teams() ([]portainer.Team, error) { // UpdateTeam saves a Team. func (service *TeamService) UpdateTeam(ID portainer.TeamID, team *portainer.Team) error { - data, err := internal.MarshalTeam(team) + data, err := internal.MarshalObject(team) if err != nil { return err } @@ -118,7 +118,7 @@ func (service *TeamService) CreateTeam(team *portainer.Team) error { id, _ := bucket.NextSequence() team.ID = portainer.TeamID(id) - data, err := internal.MarshalTeam(team) + data, err := internal.MarshalObject(team) if err != nil { return err } diff --git a/api/bolt/user_service.go b/api/bolt/user_service.go index 1e1c68f40..55723f321 100644 --- a/api/bolt/user_service.go +++ b/api/bolt/user_service.go @@ -31,7 +31,7 @@ func (service *UserService) User(ID portainer.UserID) (*portainer.User, error) { } var user portainer.User - err = internal.UnmarshalUser(data, &user) + err = internal.UnmarshalObject(data, &user) if err != nil { return nil, err } @@ -47,7 +47,7 @@ func (service *UserService) UserByUsername(username string) (*portainer.User, er cursor := bucket.Cursor() for k, v := cursor.First(); k != nil; k, v = cursor.Next() { var u portainer.User - err := internal.UnmarshalUser(v, &u) + err := internal.UnmarshalObject(v, &u) if err != nil { return err } @@ -76,7 +76,7 @@ func (service *UserService) Users() ([]portainer.User, error) { cursor := bucket.Cursor() for k, v := cursor.First(); k != nil; k, v = cursor.Next() { var user portainer.User - err := internal.UnmarshalUser(v, &user) + err := internal.UnmarshalObject(v, &user) if err != nil { return err } @@ -101,7 +101,7 @@ func (service *UserService) UsersByRole(role portainer.UserRole) ([]portainer.Us cursor := bucket.Cursor() for k, v := cursor.First(); k != nil; k, v = cursor.Next() { var user portainer.User - err := internal.UnmarshalUser(v, &user) + err := internal.UnmarshalObject(v, &user) if err != nil { return err } @@ -120,7 +120,7 @@ func (service *UserService) UsersByRole(role portainer.UserRole) ([]portainer.Us // UpdateUser saves a user. func (service *UserService) UpdateUser(ID portainer.UserID, user *portainer.User) error { - data, err := internal.MarshalUser(user) + data, err := internal.MarshalObject(user) if err != nil { return err } @@ -144,7 +144,7 @@ func (service *UserService) CreateUser(user *portainer.User) error { id, _ := bucket.NextSequence() user.ID = portainer.UserID(id) - data, err := internal.MarshalUser(user) + data, err := internal.MarshalObject(user) if err != nil { return err } From da5a430b8cb7545bef6de7576c73afb1a3235138 Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Mon, 18 Jun 2018 11:56:31 +0200 Subject: [PATCH 21/37] fix(api): add an authenticated access policy to the websocket endpoint (#1979) * fix(api): add an authenticated access policy to the websocket endpoint * refactor(api): centralize EndpointAccess validation * feat(api): validate id query parameter for the /websocket/exec endpoint --- api/http/handler/endpointproxy/handler.go | 28 ++-------- api/http/handler/endpointproxy/proxy_azure.go | 14 +---- .../handler/endpointproxy/proxy_docker.go | 14 +---- .../handler/endpointproxy/proxy_storidge.go | 14 +---- api/http/handler/stacks/handler.go | 22 +------- api/http/handler/stacks/stack_create.go | 14 +---- api/http/handler/stacks/stack_delete.go | 13 +---- api/http/handler/websocket/handler.go | 10 ++-- api/http/handler/websocket/websocket_exec.go | 12 +++- api/http/security/authorization.go | 4 +- api/http/security/bouncer.go | 55 +++++++++++++++++-- api/http/security/filter.go | 2 +- api/http/server.go | 15 +++-- .../console/containerConsoleController.js | 7 ++- 14 files changed, 100 insertions(+), 124 deletions(-) diff --git a/api/http/handler/endpointproxy/handler.go b/api/http/handler/endpointproxy/handler.go index a9543f938..cd17e0733 100644 --- a/api/http/handler/endpointproxy/handler.go +++ b/api/http/handler/endpointproxy/handler.go @@ -11,16 +11,16 @@ import ( // Handler is the HTTP handler used to proxy requests to external APIs. type Handler struct { *mux.Router - EndpointService portainer.EndpointService - EndpointGroupService portainer.EndpointGroupService - TeamMembershipService portainer.TeamMembershipService - ProxyManager *proxy.Manager + requestBouncer *security.RequestBouncer + EndpointService portainer.EndpointService + ProxyManager *proxy.Manager } // NewHandler creates a handler to proxy requests to external APIs. func NewHandler(bouncer *security.RequestBouncer) *Handler { h := &Handler{ - Router: mux.NewRouter(), + Router: mux.NewRouter(), + requestBouncer: bouncer, } h.PathPrefix("/{id}/azure").Handler( bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.proxyRequestsToAzureAPI))) @@ -30,21 +30,3 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler { bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.proxyRequestsToStoridgeAPI))) return h } - -func (handler *Handler) checkEndpointAccess(endpoint *portainer.Endpoint, userID portainer.UserID) error { - memberships, err := handler.TeamMembershipService.TeamMembershipsByUserID(userID) - if err != nil { - return err - } - - group, err := handler.EndpointGroupService.EndpointGroup(endpoint.GroupID) - if err != nil { - return err - } - - if !security.AuthorizedEndpointAccess(endpoint, group, userID, memberships) { - return portainer.ErrEndpointAccessDenied - } - - return nil -} diff --git a/api/http/handler/endpointproxy/proxy_azure.go b/api/http/handler/endpointproxy/proxy_azure.go index 1cf932393..a9b550da4 100644 --- a/api/http/handler/endpointproxy/proxy_azure.go +++ b/api/http/handler/endpointproxy/proxy_azure.go @@ -6,7 +6,6 @@ import ( "github.com/portainer/portainer" httperror "github.com/portainer/portainer/http/error" "github.com/portainer/portainer/http/request" - "github.com/portainer/portainer/http/security" "net/http" ) @@ -24,18 +23,9 @@ func (handler *Handler) proxyRequestsToAzureAPI(w http.ResponseWriter, r *http.R return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err} } - tokenData, err := security.RetrieveTokenData(r) + err = handler.requestBouncer.EndpointAccess(r, endpoint) if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve user authentication token", err} - } - - if tokenData.Role != portainer.AdministratorRole { - err = handler.checkEndpointAccess(endpoint, tokenData.ID) - if err != nil && err == portainer.ErrEndpointAccessDenied { - return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", portainer.ErrEndpointAccessDenied} - } else if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to verify permission to access endpoint", err} - } + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", portainer.ErrEndpointAccessDenied} } var proxy http.Handler diff --git a/api/http/handler/endpointproxy/proxy_docker.go b/api/http/handler/endpointproxy/proxy_docker.go index fd73b5c22..352652fc4 100644 --- a/api/http/handler/endpointproxy/proxy_docker.go +++ b/api/http/handler/endpointproxy/proxy_docker.go @@ -6,7 +6,6 @@ import ( "github.com/portainer/portainer" httperror "github.com/portainer/portainer/http/error" "github.com/portainer/portainer/http/request" - "github.com/portainer/portainer/http/security" "net/http" ) @@ -24,18 +23,9 @@ func (handler *Handler) proxyRequestsToDockerAPI(w http.ResponseWriter, r *http. return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err} } - tokenData, err := security.RetrieveTokenData(r) + err = handler.requestBouncer.EndpointAccess(r, endpoint) if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve user authentication token", err} - } - - if tokenData.Role != portainer.AdministratorRole { - err = handler.checkEndpointAccess(endpoint, tokenData.ID) - if err != nil && err == portainer.ErrEndpointAccessDenied { - return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", portainer.ErrEndpointAccessDenied} - } else if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to verify permission to access endpoint", err} - } + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", portainer.ErrEndpointAccessDenied} } var proxy http.Handler diff --git a/api/http/handler/endpointproxy/proxy_storidge.go b/api/http/handler/endpointproxy/proxy_storidge.go index 8a636205c..30e85dfda 100644 --- a/api/http/handler/endpointproxy/proxy_storidge.go +++ b/api/http/handler/endpointproxy/proxy_storidge.go @@ -6,7 +6,6 @@ import ( "github.com/portainer/portainer" httperror "github.com/portainer/portainer/http/error" "github.com/portainer/portainer/http/request" - "github.com/portainer/portainer/http/security" "net/http" ) @@ -24,18 +23,9 @@ func (handler *Handler) proxyRequestsToStoridgeAPI(w http.ResponseWriter, r *htt return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err} } - tokenData, err := security.RetrieveTokenData(r) + err = handler.requestBouncer.EndpointAccess(r, endpoint) if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve user authentication token", err} - } - - if tokenData.Role != portainer.AdministratorRole { - err = handler.checkEndpointAccess(endpoint, tokenData.ID) - if err != nil && err == portainer.ErrEndpointAccessDenied { - return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", portainer.ErrEndpointAccessDenied} - } else if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to verify permission to access endpoint", err} - } + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", portainer.ErrEndpointAccessDenied} } var storidgeExtension *portainer.EndpointExtension diff --git a/api/http/handler/stacks/handler.go b/api/http/handler/stacks/handler.go index 9ce1bab17..d907d52d2 100644 --- a/api/http/handler/stacks/handler.go +++ b/api/http/handler/stacks/handler.go @@ -14,13 +14,12 @@ import ( type Handler struct { stackCreationMutex *sync.Mutex stackDeletionMutex *sync.Mutex + requestBouncer *security.RequestBouncer *mux.Router FileService portainer.FileService GitService portainer.GitService StackService portainer.StackService EndpointService portainer.EndpointService - EndpointGroupService portainer.EndpointGroupService - TeamMembershipService portainer.TeamMembershipService ResourceControlService portainer.ResourceControlService RegistryService portainer.RegistryService DockerHubService portainer.DockerHubService @@ -34,6 +33,7 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler { Router: mux.NewRouter(), stackCreationMutex: &sync.Mutex{}, stackDeletionMutex: &sync.Mutex{}, + requestBouncer: bouncer, } h.Handle("/stacks", bouncer.RestrictedAccess(httperror.LoggerHandler(h.stackCreate))).Methods(http.MethodPost) @@ -49,21 +49,3 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler { bouncer.RestrictedAccess(httperror.LoggerHandler(h.stackFile))).Methods(http.MethodGet) return h } - -func (handler *Handler) checkEndpointAccess(endpoint *portainer.Endpoint, userID portainer.UserID) error { - memberships, err := handler.TeamMembershipService.TeamMembershipsByUserID(userID) - if err != nil { - return err - } - - group, err := handler.EndpointGroupService.EndpointGroup(endpoint.GroupID) - if err != nil { - return err - } - - if !security.AuthorizedEndpointAccess(endpoint, group, userID, memberships) { - return portainer.ErrEndpointAccessDenied - } - - return nil -} diff --git a/api/http/handler/stacks/stack_create.go b/api/http/handler/stacks/stack_create.go index dac491f69..221ef2c4f 100644 --- a/api/http/handler/stacks/stack_create.go +++ b/api/http/handler/stacks/stack_create.go @@ -7,7 +7,6 @@ import ( "github.com/portainer/portainer" httperror "github.com/portainer/portainer/http/error" "github.com/portainer/portainer/http/request" - "github.com/portainer/portainer/http/security" ) func (handler *Handler) cleanUp(stack *portainer.Stack, doCleanUp *bool) error { @@ -47,18 +46,9 @@ func (handler *Handler) stackCreate(w http.ResponseWriter, r *http.Request) *htt return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err} } - tokenData, err := security.RetrieveTokenData(r) + err = handler.requestBouncer.EndpointAccess(r, endpoint) if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve user authentication token", err} - } - - if tokenData.Role != portainer.AdministratorRole { - err = handler.checkEndpointAccess(endpoint, tokenData.ID) - if err != nil && err == portainer.ErrEndpointAccessDenied { - return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", portainer.ErrEndpointAccessDenied} - } else if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to verify permission to access endpoint", err} - } + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", portainer.ErrEndpointAccessDenied} } switch portainer.StackType(stackType) { diff --git a/api/http/handler/stacks/stack_delete.go b/api/http/handler/stacks/stack_delete.go index 5e1bcceaf..3ca5378dd 100644 --- a/api/http/handler/stacks/stack_delete.go +++ b/api/http/handler/stacks/stack_delete.go @@ -105,18 +105,9 @@ func (handler *Handler) deleteExternalStack(r *http.Request, w http.ResponseWrit return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find the endpoint associated to the stack inside the database", err} } - tokenData, err := security.RetrieveTokenData(r) + err = handler.requestBouncer.EndpointAccess(r, endpoint) if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve user authentication token", err} - } - - if tokenData.Role != portainer.AdministratorRole { - err = handler.checkEndpointAccess(endpoint, tokenData.ID) - if err != nil && err == portainer.ErrEndpointAccessDenied { - return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", portainer.ErrEndpointAccessDenied} - } else if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to verify permission to access endpoint", err} - } + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", portainer.ErrEndpointAccessDenied} } stack = &portainer.Stack{ diff --git a/api/http/handler/websocket/handler.go b/api/http/handler/websocket/handler.go index 34dcce593..20364f52b 100644 --- a/api/http/handler/websocket/handler.go +++ b/api/http/handler/websocket/handler.go @@ -1,12 +1,11 @@ package websocket import ( - "net/http" - "github.com/gorilla/mux" "github.com/gorilla/websocket" "github.com/portainer/portainer" httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/security" ) // Handler is the HTTP handler used to handle websocket operations. @@ -14,15 +13,18 @@ type Handler struct { *mux.Router EndpointService portainer.EndpointService SignatureService portainer.DigitalSignatureService + requestBouncer *security.RequestBouncer connectionUpgrader websocket.Upgrader } // NewHandler creates a handler to manage websocket operations. -func NewHandler() *Handler { +func NewHandler(bouncer *security.RequestBouncer) *Handler { h := &Handler{ Router: mux.NewRouter(), connectionUpgrader: websocket.Upgrader{}, + requestBouncer: bouncer, } - h.Handle("/websocket/exec", httperror.LoggerHandler(h.websocketExec)).Methods(http.MethodGet) + h.PathPrefix("/websocket/exec").Handler( + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.websocketExec))) return h } diff --git a/api/http/handler/websocket/websocket_exec.go b/api/http/handler/websocket/websocket_exec.go index 98cb6d983..cbb9fbbf4 100644 --- a/api/http/handler/websocket/websocket_exec.go +++ b/api/http/handler/websocket/websocket_exec.go @@ -12,6 +12,7 @@ import ( "net/url" "time" + "github.com/asaskevich/govalidator" "github.com/gorilla/websocket" "github.com/koding/websocketproxy" "github.com/portainer/portainer" @@ -31,15 +32,19 @@ type execStartOperationPayload struct { Detach bool } -// websocketExec handles GET requests on /websocket/exec?id=&endpointId=&nodeName= +// websocketExec handles GET requests on /websocket/exec?id=&endpointId=&nodeName=&token= // If the nodeName query parameter is present, the request will be proxied to the underlying agent endpoint. // If the nodeName query parameter is not specified, the request will be upgraded to the websocket protocol and // an ExecStart operation HTTP request will be created and hijacked. +// Authentication and access is controled via the mandatory token query parameter. func (handler *Handler) websocketExec(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { execID, err := request.RetrieveQueryParameter(r, "id", false) if err != nil { return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: id", err} } + if !govalidator.IsHexadecimal(execID) { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: id (must be hexadecimal identifier)", err} + } endpointID, err := request.RetrieveNumericQueryParameter(r, "endpointId", false) if err != nil { @@ -53,6 +58,11 @@ func (handler *Handler) websocketExec(w http.ResponseWriter, r *http.Request) *h return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find the endpoint associated to the stack inside the database", err} } + err = handler.requestBouncer.EndpointAccess(r, endpoint) + if err != nil { + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", portainer.ErrEndpointAccessDenied} + } + params := &webSocketExecRequestParams{ endpoint: endpoint, execID: execID, diff --git a/api/http/security/authorization.go b/api/http/security/authorization.go index 155869632..7fa7a6f31 100644 --- a/api/http/security/authorization.go +++ b/api/http/security/authorization.go @@ -166,10 +166,10 @@ func AuthorizedUserManagement(userID portainer.UserID, context *RestrictedReques return false } -// AuthorizedEndpointAccess ensure that the user can access the specified endpoint. +// authorizedEndpointAccess ensure that the user can access the specified endpoint. // It will check if the user is part of the authorized users or part of a team that is // listed in the authorized teams of the endpoint and the associated group. -func AuthorizedEndpointAccess(endpoint *portainer.Endpoint, endpointGroup *portainer.EndpointGroup, userID portainer.UserID, memberships []portainer.TeamMembership) bool { +func authorizedEndpointAccess(endpoint *portainer.Endpoint, endpointGroup *portainer.EndpointGroup, userID portainer.UserID, memberships []portainer.TeamMembership) bool { groupAccess := authorizedAccess(userID, memberships, endpointGroup.AuthorizedUsers, endpointGroup.AuthorizedTeams) if !groupAccess { return authorizedAccess(userID, memberships, endpoint.AuthorizedUsers, endpoint.AuthorizedTeams) diff --git a/api/http/security/bouncer.go b/api/http/security/bouncer.go index 1e3e7d522..1327503eb 100644 --- a/api/http/security/bouncer.go +++ b/api/http/security/bouncer.go @@ -14,9 +14,19 @@ type ( jwtService portainer.JWTService userService portainer.UserService teamMembershipService portainer.TeamMembershipService + endpointGroupService portainer.EndpointGroupService authDisabled bool } + // RequestBouncerParams represents the required parameters to create a new RequestBouncer instance. + RequestBouncerParams struct { + JWTService portainer.JWTService + UserService portainer.UserService + TeamMembershipService portainer.TeamMembershipService + EndpointGroupService portainer.EndpointGroupService + AuthDisabled bool + } + // RestrictedRequestContext is a data structure containing information // used in RestrictedAccess RestrictedRequestContext struct { @@ -28,12 +38,13 @@ type ( ) // NewRequestBouncer initializes a new RequestBouncer -func NewRequestBouncer(jwtService portainer.JWTService, userService portainer.UserService, teamMembershipService portainer.TeamMembershipService, authDisabled bool) *RequestBouncer { +func NewRequestBouncer(parameters *RequestBouncerParams) *RequestBouncer { return &RequestBouncer{ - jwtService: jwtService, - userService: userService, - teamMembershipService: teamMembershipService, - authDisabled: authDisabled, + jwtService: parameters.JWTService, + userService: parameters.UserService, + teamMembershipService: parameters.TeamMembershipService, + endpointGroupService: parameters.EndpointGroupService, + authDisabled: parameters.AuthDisabled, } } @@ -70,6 +81,36 @@ func (bouncer *RequestBouncer) AdministratorAccess(h http.Handler) http.Handler return h } +// EndpointAccess retrieves the JWT token from the request context and verifies +// that the user can access the specified endpoint. +// An error is returned when access is denied. +func (bouncer *RequestBouncer) EndpointAccess(r *http.Request, endpoint *portainer.Endpoint) error { + tokenData, err := RetrieveTokenData(r) + if err != nil { + return err + } + + if tokenData.Role == portainer.AdministratorRole { + return nil + } + + memberships, err := bouncer.teamMembershipService.TeamMembershipsByUserID(tokenData.ID) + if err != nil { + return err + } + + group, err := bouncer.endpointGroupService.EndpointGroup(endpoint.GroupID) + if err != nil { + return err + } + + if !authorizedEndpointAccess(endpoint, group, tokenData.ID, memberships) { + return portainer.ErrEndpointAccessDenied + } + + return nil +} + // mwSecureHeaders provides secure headers middleware for handlers. func mwSecureHeaders(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -120,6 +161,10 @@ func (bouncer *RequestBouncer) mwCheckAuthentication(next http.Handler) http.Han if !bouncer.authDisabled { var token string + // Optionally, token might be set via the "token" query parameter. + // For example, in websocket requests + token = r.URL.Query().Get("token") + // Get token from the Authorization header tokens, ok := r.Header["Authorization"] if ok && len(tokens) >= 1 { diff --git a/api/http/security/filter.go b/api/http/security/filter.go index 71bd314b4..0e00ab568 100644 --- a/api/http/security/filter.go +++ b/api/http/security/filter.go @@ -88,7 +88,7 @@ func FilterEndpoints(endpoints []portainer.Endpoint, groups []portainer.Endpoint for _, endpoint := range endpoints { endpointGroup := getAssociatedGroup(&endpoint, groups) - if AuthorizedEndpointAccess(&endpoint, endpointGroup, context.UserID, context.UserMemberships) { + if authorizedEndpointAccess(&endpoint, endpointGroup, context.UserID, context.UserMemberships) { filteredEndpoints = append(filteredEndpoints, endpoint) } } diff --git a/api/http/server.go b/api/http/server.go index 9c476c74d..7569760dd 100644 --- a/api/http/server.go +++ b/api/http/server.go @@ -64,7 +64,14 @@ type Server struct { // Start starts the HTTP server func (server *Server) Start() error { - requestBouncer := security.NewRequestBouncer(server.JWTService, server.UserService, server.TeamMembershipService, server.AuthDisabled) + requestBouncerParameters := &security.RequestBouncerParams{ + JWTService: server.JWTService, + UserService: server.UserService, + TeamMembershipService: server.TeamMembershipService, + EndpointGroupService: server.EndpointGroupService, + AuthDisabled: server.AuthDisabled, + } + requestBouncer := security.NewRequestBouncer(requestBouncerParameters) proxyManagerParameters := &proxy.ManagerParams{ ResourceControlService: server.ResourceControlService, TeamMembershipService: server.TeamMembershipService, @@ -98,8 +105,6 @@ func (server *Server) Start() error { var endpointProxyHandler = endpointproxy.NewHandler(requestBouncer) endpointProxyHandler.EndpointService = server.EndpointService - endpointProxyHandler.EndpointGroupService = server.EndpointGroupService - endpointProxyHandler.TeamMembershipService = server.TeamMembershipService endpointProxyHandler.ProxyManager = proxyManager var fileHandler = file.NewHandler(filepath.Join(server.AssetsPath, "public")) @@ -119,8 +124,6 @@ func (server *Server) Start() error { stackHandler.FileService = server.FileService stackHandler.StackService = server.StackService stackHandler.EndpointService = server.EndpointService - stackHandler.EndpointGroupService = server.EndpointGroupService - stackHandler.TeamMembershipService = server.TeamMembershipService stackHandler.ResourceControlService = server.ResourceControlService stackHandler.SwarmStackManager = server.SwarmStackManager stackHandler.ComposeStackManager = server.ComposeStackManager @@ -153,7 +156,7 @@ func (server *Server) Start() error { userHandler.ResourceControlService = server.ResourceControlService userHandler.SettingsService = server.SettingsService - var websocketHandler = websocket.NewHandler() + var websocketHandler = websocket.NewHandler(requestBouncer) websocketHandler.EndpointService = server.EndpointService websocketHandler.SignatureService = server.SignatureService diff --git a/app/docker/views/containers/console/containerConsoleController.js b/app/docker/views/containers/console/containerConsoleController.js index 1f9353a77..f877c5836 100644 --- a/app/docker/views/containers/console/containerConsoleController.js +++ b/app/docker/views/containers/console/containerConsoleController.js @@ -1,6 +1,6 @@ angular.module('portainer.docker') -.controller('ContainerConsoleController', ['$scope', '$transition$', 'ContainerService', 'ImageService', 'EndpointProvider', 'Notifications', 'ContainerHelper', 'ExecService', 'HttpRequestHelper', -function ($scope, $transition$, ContainerService, ImageService, EndpointProvider, Notifications, ContainerHelper, ExecService, HttpRequestHelper) { +.controller('ContainerConsoleController', ['$scope', '$transition$', 'ContainerService', 'ImageService', 'EndpointProvider', 'Notifications', 'ContainerHelper', 'ExecService', 'HttpRequestHelper', 'LocalStorage', +function ($scope, $transition$, ContainerService, ImageService, EndpointProvider, Notifications, ContainerHelper, ExecService, HttpRequestHelper, LocalStorage) { var socket, term; $scope.state = { @@ -36,7 +36,8 @@ function ($scope, $transition$, ContainerService, ImageService, EndpointProvider ContainerService.createExec(execConfig) .then(function success(data) { execId = data.Id; - var url = window.location.href.split('#')[0] + 'api/websocket/exec?id=' + execId + '&endpointId=' + EndpointProvider.endpointID(); + var jwtToken = LocalStorage.getJWT(); + var url = window.location.href.split('#')[0] + 'api/websocket/exec?id=' + execId + '&endpointId=' + EndpointProvider.endpointID() + '&token=' + jwtToken; if ($transition$.params().nodeName) { url += '&nodeName=' + $transition$.params().nodeName; } From b4c2820ad702326774fc7c14480f7b143628585a Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Mon, 18 Jun 2018 12:07:56 +0200 Subject: [PATCH 22/37] refactor(api): use a standard stack identifier (#1980) --- api/bolt/datastore.go | 4 +- api/bolt/migrate_dbversion11.go | 88 +++++++++++++++++-- api/bolt/migrator.go | 1 + api/bolt/stack_service.go | 27 +++++- api/cmd/portainer/main.go | 6 +- api/filesystem/filesystem.go | 5 ++ .../handler/stacks/create_compose_stack.go | 12 +-- api/http/handler/stacks/create_swarm_stack.go | 12 +-- api/http/handler/stacks/stack_create.go | 11 ++- api/http/handler/stacks/stack_delete.go | 12 ++- api/http/handler/stacks/stack_file.go | 2 +- api/http/handler/stacks/stack_inspect.go | 2 +- api/http/handler/stacks/stack_update.go | 2 +- api/portainer.go | 4 +- 14 files changed, 150 insertions(+), 38 deletions(-) diff --git a/api/bolt/datastore.go b/api/bolt/datastore.go index 37ad96ce7..d5002a413 100644 --- a/api/bolt/datastore.go +++ b/api/bolt/datastore.go @@ -31,6 +31,7 @@ type Store struct { db *bolt.DB checkForDataMigration bool + FileService portainer.FileService } const ( @@ -50,7 +51,7 @@ const ( ) // NewStore initializes a new Store and the associated services -func NewStore(storePath string) (*Store, error) { +func NewStore(storePath string, fileService portainer.FileService) (*Store, error) { store := &Store{ Path: storePath, UserService: &UserService{}, @@ -65,6 +66,7 @@ func NewStore(storePath string) (*Store, error) { DockerHubService: &DockerHubService{}, StackService: &StackService{}, TagService: &TagService{}, + FileService: fileService, } store.UserService.store = store store.TeamService.store = store diff --git a/api/bolt/migrate_dbversion11.go b/api/bolt/migrate_dbversion11.go index dcd0e1100..b53e3fcbf 100644 --- a/api/bolt/migrate_dbversion11.go +++ b/api/bolt/migrate_dbversion11.go @@ -1,6 +1,13 @@ package bolt -import "github.com/portainer/portainer" +import ( + "strconv" + "strings" + + "github.com/boltdb/bolt" + "github.com/portainer/portainer" + "github.com/portainer/portainer/bolt/internal" +) func (m *Migrator) updateEndpointsToVersion12() error { legacyEndpoints, err := m.EndpointService.Endpoints() @@ -38,16 +45,24 @@ func (m *Migrator) updateEndpointGroupsToVersion12() error { return nil } +type legacyStack struct { + ID string `json:"Id"` + Name string `json:"Name"` + EndpointID portainer.EndpointID `json:"EndpointId"` + SwarmID string `json:"SwarmId"` + EntryPoint string `json:"EntryPoint"` + Env []portainer.Pair `json:"Env"` + ProjectPath string +} + func (m *Migrator) updateStacksToVersion12() error { - legacyStacks, err := m.StackService.Stacks() + legacyStacks, err := m.retrieveLegacyStacks() if err != nil { return err } - for _, stack := range legacyStacks { - stack.Type = portainer.DockerSwarmStack - - err = m.StackService.UpdateStack(stack.ID, &stack) + for _, legacyStack := range legacyStacks { + err := m.convertLegacyStack(&legacyStack) if err != nil { return err } @@ -55,3 +70,64 @@ func (m *Migrator) updateStacksToVersion12() error { return nil } + +func (m *Migrator) convertLegacyStack(s *legacyStack) error { + stackID := m.StackService.GetNextIdentifier() + stack := &portainer.Stack{ + ID: portainer.StackID(stackID), + Name: s.Name, + Type: portainer.DockerSwarmStack, + SwarmID: s.SwarmID, + EndpointID: 0, + EntryPoint: s.EntryPoint, + Env: s.Env, + } + + stack.ProjectPath = strings.Replace(s.ProjectPath, s.ID, strconv.Itoa(stackID), 1) + err := m.store.FileService.Rename(s.ProjectPath, stack.ProjectPath) + if err != nil { + return err + } + + err = m.deleteLegacyStack(s.ID) + if err != nil { + return err + } + + return m.StackService.CreateStack(stack) +} + +func (m *Migrator) deleteLegacyStack(legacyID string) error { + return m.store.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(stackBucketName)) + err := bucket.Delete([]byte(legacyID)) + if err != nil { + return err + } + return nil + }) +} + +func (m *Migrator) retrieveLegacyStacks() ([]legacyStack, error) { + var legacyStacks = make([]legacyStack, 0) + err := m.store.db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(stackBucketName)) + cursor := bucket.Cursor() + + for k, v := cursor.First(); k != nil; k, v = cursor.Next() { + var stack legacyStack + err := internal.UnmarshalObject(v, &stack) + if err != nil { + return err + } + legacyStacks = append(legacyStacks, stack) + } + + return nil + }) + if err != nil { + return nil, err + } + + return legacyStacks, nil +} diff --git a/api/bolt/migrator.go b/api/bolt/migrator.go index ef3901d42..84ede8156 100644 --- a/api/bolt/migrator.go +++ b/api/bolt/migrator.go @@ -14,6 +14,7 @@ type Migrator struct { StackService *StackService UserService *UserService VersionService *VersionService + FileService portainer.FileService } // NewMigrator creates a new Migrator. diff --git a/api/bolt/stack_service.go b/api/bolt/stack_service.go index 60230341b..34855e59f 100644 --- a/api/bolt/stack_service.go +++ b/api/bolt/stack_service.go @@ -17,7 +17,7 @@ func (service *StackService) Stack(ID portainer.StackID) (*portainer.Stack, erro var data []byte err := service.store.db.View(func(tx *bolt.Tx) error { bucket := tx.Bucket([]byte(stackBucketName)) - value := bucket.Get([]byte(ID)) + value := bucket.Get(internal.Itob(int(ID))) if value == nil { return portainer.ErrStackNotFound } @@ -92,17 +92,36 @@ func (service *StackService) Stacks() ([]portainer.Stack, error) { return stacks, nil } +// GetNextIdentifier returns the current bucket identifier incremented by 1. +func (service *StackService) GetNextIdentifier() int { + var identifier int + + service.store.db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(stackBucketName)) + id := bucket.Sequence() + identifier = int(id) + return nil + }) + + identifier++ + return identifier +} + // CreateStack creates a new stack. func (service *StackService) CreateStack(stack *portainer.Stack) error { return service.store.db.Update(func(tx *bolt.Tx) error { bucket := tx.Bucket([]byte(stackBucketName)) + err := bucket.SetSequence(uint64(stack.ID)) + if err != nil { + return err + } data, err := internal.MarshalObject(stack) if err != nil { return err } - err = bucket.Put([]byte(stack.ID), data) + err = bucket.Put(internal.Itob(int(stack.ID)), data) if err != nil { return err } @@ -119,7 +138,7 @@ func (service *StackService) UpdateStack(ID portainer.StackID, stack *portainer. return service.store.db.Update(func(tx *bolt.Tx) error { bucket := tx.Bucket([]byte(stackBucketName)) - err = bucket.Put([]byte(ID), data) + err = bucket.Put(internal.Itob(int(ID)), data) if err != nil { return err } @@ -131,7 +150,7 @@ func (service *StackService) UpdateStack(ID portainer.StackID, stack *portainer. func (service *StackService) DeleteStack(ID portainer.StackID) error { return service.store.db.Update(func(tx *bolt.Tx) error { bucket := tx.Bucket([]byte(stackBucketName)) - err := bucket.Delete([]byte(ID)) + err := bucket.Delete(internal.Itob(int(ID))) if err != nil { return err } diff --git a/api/cmd/portainer/main.go b/api/cmd/portainer/main.go index 4f37e7846..cd4921590 100644 --- a/api/cmd/portainer/main.go +++ b/api/cmd/portainer/main.go @@ -42,8 +42,8 @@ func initFileService(dataStorePath string) portainer.FileService { return fileService } -func initStore(dataStorePath string) *bolt.Store { - store, err := bolt.NewStore(dataStorePath) +func initStore(dataStorePath string, fileService portainer.FileService) *bolt.Store { + store, err := bolt.NewStore(dataStorePath, fileService) if err != nil { log.Fatal(err) } @@ -307,7 +307,7 @@ func main() { fileService := initFileService(*flags.Data) - store := initStore(*flags.Data) + store := initStore(*flags.Data, fileService) defer store.Close() jwtService := initJWTService(!*flags.NoAuth) diff --git a/api/filesystem/filesystem.go b/api/filesystem/filesystem.go index c3f459ddf..b0efbfe8f 100644 --- a/api/filesystem/filesystem.go +++ b/api/filesystem/filesystem.go @@ -186,6 +186,11 @@ func (service *Service) GetFileContent(filePath string) (string, error) { return string(content), nil } +// Rename renames a file or directory +func (service *Service) Rename(oldPath, newPath string) error { + return os.Rename(oldPath, newPath) +} + // WriteJSONToFile writes JSON to the specified file. func (service *Service) WriteJSONToFile(path string, content interface{}) error { jsonContent, err := json.Marshal(content) diff --git a/api/http/handler/stacks/create_compose_stack.go b/api/http/handler/stacks/create_compose_stack.go index c1321e297..2738e255b 100644 --- a/api/http/handler/stacks/create_compose_stack.go +++ b/api/http/handler/stacks/create_compose_stack.go @@ -46,9 +46,9 @@ func (handler *Handler) createComposeStackFromFileContent(w http.ResponseWriter, } } - stackIdentifier := buildStackIdentifier(payload.Name, endpoint.ID) + stackID := handler.StackService.GetNextIdentifier() stack := &portainer.Stack{ - ID: portainer.StackID(stackIdentifier), + ID: portainer.StackID(stackID), Name: payload.Name, Type: portainer.DockerComposeStack, EndpointID: endpoint.ID, @@ -126,9 +126,9 @@ func (handler *Handler) createComposeStackFromGitRepository(w http.ResponseWrite } } - stackIdentifier := buildStackIdentifier(payload.Name, endpoint.ID) + stackID := handler.StackService.GetNextIdentifier() stack := &portainer.Stack{ - ID: portainer.StackID(stackIdentifier), + ID: portainer.StackID(stackID), Name: payload.Name, Type: portainer.DockerComposeStack, EndpointID: endpoint.ID, @@ -211,9 +211,9 @@ func (handler *Handler) createComposeStackFromFileUpload(w http.ResponseWriter, } } - stackIdentifier := buildStackIdentifier(payload.Name, endpoint.ID) + stackID := handler.StackService.GetNextIdentifier() stack := &portainer.Stack{ - ID: portainer.StackID(stackIdentifier), + ID: portainer.StackID(stackID), Name: payload.Name, Type: portainer.DockerComposeStack, EndpointID: endpoint.ID, diff --git a/api/http/handler/stacks/create_swarm_stack.go b/api/http/handler/stacks/create_swarm_stack.go index 0a87fb53f..aeb99e3f5 100644 --- a/api/http/handler/stacks/create_swarm_stack.go +++ b/api/http/handler/stacks/create_swarm_stack.go @@ -51,9 +51,9 @@ func (handler *Handler) createSwarmStackFromFileContent(w http.ResponseWriter, r } } - stackIdentifier := buildStackIdentifier(payload.Name, endpoint.ID) + stackID := handler.StackService.GetNextIdentifier() stack := &portainer.Stack{ - ID: portainer.StackID(stackIdentifier), + ID: portainer.StackID(stackID), Name: payload.Name, Type: portainer.DockerSwarmStack, SwarmID: payload.SwarmID, @@ -138,9 +138,9 @@ func (handler *Handler) createSwarmStackFromGitRepository(w http.ResponseWriter, } } - stackIdentifier := buildStackIdentifier(payload.Name, endpoint.ID) + stackID := handler.StackService.GetNextIdentifier() stack := &portainer.Stack{ - ID: portainer.StackID(stackIdentifier), + ID: portainer.StackID(stackID), Name: payload.Name, Type: portainer.DockerSwarmStack, SwarmID: payload.SwarmID, @@ -239,9 +239,9 @@ func (handler *Handler) createSwarmStackFromFileUpload(w http.ResponseWriter, r } } - stackIdentifier := buildStackIdentifier(payload.Name, endpoint.ID) + stackID := handler.StackService.GetNextIdentifier() stack := &portainer.Stack{ - ID: portainer.StackID(stackIdentifier), + ID: portainer.StackID(stackID), Name: payload.Name, Type: portainer.DockerSwarmStack, SwarmID: payload.SwarmID, diff --git a/api/http/handler/stacks/stack_create.go b/api/http/handler/stacks/stack_create.go index 221ef2c4f..9a4c37c52 100644 --- a/api/http/handler/stacks/stack_create.go +++ b/api/http/handler/stacks/stack_create.go @@ -1,8 +1,8 @@ package stacks import ( + "log" "net/http" - "strconv" "github.com/portainer/portainer" httperror "github.com/portainer/portainer/http/error" @@ -14,14 +14,13 @@ func (handler *Handler) cleanUp(stack *portainer.Stack, doCleanUp *bool) error { return nil } - handler.FileService.RemoveDirectory(stack.ProjectPath) + err := handler.FileService.RemoveDirectory(stack.ProjectPath) + if err != nil { + log.Printf("http error: Unable to cleanup stack creation (err=%s)\n", err) + } return nil } -func buildStackIdentifier(stackName string, endpointID portainer.EndpointID) string { - return stackName + "_" + strconv.Itoa(int(endpointID)) -} - // POST request on /api/stacks?type=&method=&endpointId= func (handler *Handler) stackCreate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { stackType, err := request.RetrieveNumericQueryParameter(r, "type", false) diff --git a/api/http/handler/stacks/stack_delete.go b/api/http/handler/stacks/stack_delete.go index 3ca5378dd..c46905585 100644 --- a/api/http/handler/stacks/stack_delete.go +++ b/api/http/handler/stacks/stack_delete.go @@ -2,6 +2,7 @@ package stacks import ( "net/http" + "strconv" "github.com/portainer/portainer" httperror "github.com/portainer/portainer/http/error" @@ -12,6 +13,8 @@ import ( ) // DELETE request on /api/stacks/:id?external=&endpointId= +// If the external query parameter is set to true, the id route variable is expected to be +// the name of an external stack as a string. func (handler *Handler) stackDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { stackID, err := request.RetrieveRouteVariableValue(r, "id") if err != nil { @@ -23,7 +26,12 @@ func (handler *Handler) stackDelete(w http.ResponseWriter, r *http.Request) *htt return handler.deleteExternalStack(r, w, stackID) } - stack, err := handler.StackService.Stack(portainer.StackID(stackID)) + id, err := strconv.Atoi(stackID) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid stack identifier route variable", err} + } + + stack, err := handler.StackService.Stack(portainer.StackID(id)) if err == portainer.ErrStackNotFound { return &httperror.HandlerError{http.StatusNotFound, "Unable to find a stack with the specified identifier inside the database", err} } else if err != nil { @@ -71,7 +79,7 @@ func (handler *Handler) stackDelete(w http.ResponseWriter, r *http.Request) *htt return &httperror.HandlerError{http.StatusInternalServerError, err.Error(), err} } - err = handler.StackService.DeleteStack(portainer.StackID(stackID)) + err = handler.StackService.DeleteStack(portainer.StackID(id)) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove the stack from the database", err} } diff --git a/api/http/handler/stacks/stack_file.go b/api/http/handler/stacks/stack_file.go index b476a2e47..0875ae8e1 100644 --- a/api/http/handler/stacks/stack_file.go +++ b/api/http/handler/stacks/stack_file.go @@ -18,7 +18,7 @@ type stackFileResponse struct { // GET request on /api/stacks/:id/file func (handler *Handler) stackFile(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { - stackID, err := request.RetrieveRouteVariableValue(r, "id") + stackID, err := request.RetrieveNumericRouteVariableValue(r, "id") if err != nil { return &httperror.HandlerError{http.StatusBadRequest, "Invalid stack identifier route variable", err} } diff --git a/api/http/handler/stacks/stack_inspect.go b/api/http/handler/stacks/stack_inspect.go index 66a94a601..ec6d99509 100644 --- a/api/http/handler/stacks/stack_inspect.go +++ b/api/http/handler/stacks/stack_inspect.go @@ -13,7 +13,7 @@ import ( // GET request on /api/stacks/:id func (handler *Handler) stackInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { - stackID, err := request.RetrieveRouteVariableValue(r, "id") + stackID, err := request.RetrieveNumericRouteVariableValue(r, "id") if err != nil { return &httperror.HandlerError{http.StatusBadRequest, "Invalid stack identifier route variable", err} } diff --git a/api/http/handler/stacks/stack_update.go b/api/http/handler/stacks/stack_update.go index 9867c3887..f20f92957 100644 --- a/api/http/handler/stacks/stack_update.go +++ b/api/http/handler/stacks/stack_update.go @@ -38,7 +38,7 @@ func (payload *updateSwarmStackPayload) Validate(r *http.Request) error { // PUT request on /api/stacks/:id?endpointId= func (handler *Handler) stackUpdate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { - stackID, err := request.RetrieveRouteVariableValue(r, "id") + stackID, err := request.RetrieveNumericRouteVariableValue(r, "id") if err != nil { return &httperror.HandlerError{http.StatusBadRequest, "Invalid stack identifier route variable", err} } diff --git a/api/portainer.go b/api/portainer.go index bfe84a213..37f1e5e19 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -129,7 +129,7 @@ type ( } // StackID represents a stack identifier (it must be composed of Name + "_" + SwarmID to create a unique identifier). - StackID string + StackID int // StackType represents the type of the stack (compose v2, stack deploy v3). StackType int @@ -373,6 +373,7 @@ type ( CreateStack(stack *Stack) error UpdateStack(ID StackID, stack *Stack) error DeleteStack(ID StackID) error + GetNextIdentifier() int } // DockerHubService represents a service for managing the DockerHub object. @@ -434,6 +435,7 @@ type ( // FileService represents a service for managing files. FileService interface { GetFileContent(filePath string) (string, error) + Rename(oldPath, newPath string) error RemoveDirectory(directoryPath string) error StoreTLSFileFromBytes(folder string, fileType TLSFileType, data []byte) (string, error) GetPathForTLSFile(folder string, fileType TLSFileType) (string, error) From 6698173bf5a6a46cd66a0214b586f13a69fa2ae1 Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Mon, 18 Jun 2018 15:30:44 +0300 Subject: [PATCH 23/37] fix(api): fix endpointExtensionAddPayload validation --- api/http/handler/endpoints/endpoint_extension_add.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/http/handler/endpoints/endpoint_extension_add.go b/api/http/handler/endpoints/endpoint_extension_add.go index 25bf1ed71..e46ba06ce 100644 --- a/api/http/handler/endpoints/endpoint_extension_add.go +++ b/api/http/handler/endpoints/endpoint_extension_add.go @@ -19,8 +19,8 @@ func (payload *endpointExtensionAddPayload) Validate(r *http.Request) error { if payload.Type != 1 { return portainer.Error("Invalid type value. Value must be one of: 1 (Storidge)") } - if govalidator.IsNull(payload.URL) { - return portainer.Error("Invalid URL") + if payload.Type == 1 && govalidator.IsNull(payload.URL) { + return portainer.Error("Invalid extension URL") } return nil } From d7ff14777f610c93301b17e7ff0b1301df19c0e8 Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Tue, 19 Jun 2018 13:15:10 +0200 Subject: [PATCH 24/37] refactor(api): restructure bolt package (#1981) * refactor(api): bolt package refactor * refactor(api): refactor bolt package --- api/bolt/datastore.go | 231 +++++++++++------- api/bolt/dockerhub/dockerhub.go | 48 ++++ api/bolt/dockerhub_service.go | 61 ----- api/bolt/endpoint/endpoint.go | 138 +++++++++++ api/bolt/endpoint_group_service.go | 114 --------- api/bolt/endpoint_service.go | 154 ------------ api/bolt/endpointgroup/endpointgroup.go | 95 +++++++ api/bolt/internal/db.go | 94 +++++++ api/bolt/internal/{internal.go => json.go} | 10 - api/bolt/migrate_dbversion4.go | 16 -- api/bolt/migrate_dbversion5.go | 16 -- api/bolt/migrate_dbversion6.go | 16 -- api/bolt/migrator.go | 152 ------------ api/bolt/{ => migrator}/migrate_dbversion0.go | 19 +- api/bolt/{ => migrator}/migrate_dbversion1.go | 12 +- .../{ => migrator}/migrate_dbversion10.go | 6 +- .../{ => migrator}/migrate_dbversion11.go | 36 ++- api/bolt/{ => migrator}/migrate_dbversion2.go | 11 +- api/bolt/{ => migrator}/migrate_dbversion3.go | 7 +- api/bolt/migrator/migrate_dbversion4.go | 11 + api/bolt/migrator/migrate_dbversion5.go | 11 + api/bolt/migrator/migrate_dbversion6.go | 11 + api/bolt/{ => migrator}/migrate_dbversion7.go | 6 +- api/bolt/{ => migrator}/migrate_dbversion8.go | 6 +- api/bolt/{ => migrator}/migrate_dbversion9.go | 6 +- api/bolt/migrator/migrator.go | 174 +++++++++++++ api/bolt/registry/registry.go | 95 +++++++ api/bolt/registry_service.go | 114 --------- api/bolt/resource_control_service.go | 148 ----------- api/bolt/resourcecontrol/resourcecontrol.go | 134 ++++++++++ api/bolt/settings/settings.go | 48 ++++ api/bolt/settings_service.go | 61 ----- api/bolt/stack/stack.go | 134 ++++++++++ api/bolt/stack_service.go | 159 ------------ api/bolt/tag/tag.go | 76 ++++++ api/bolt/tag_service.go | 71 ------ api/bolt/team/team.go | 126 ++++++++++ api/bolt/team_service.go | 144 ----------- .../teammembership.go} | 142 +++++------ api/bolt/user/user.go | 149 +++++++++++ api/bolt/user_service.go | 170 ------------- api/bolt/version/version.go | 66 +++++ api/bolt/version_service.go | 58 ----- api/cmd/portainer/main.go | 8 +- api/errors.go | 26 +- api/filesystem/filesystem.go | 25 +- api/http/handler/auth/authenticate.go | 2 +- .../handler/dockerhub/dockerhub_update.go | 2 +- .../endpointgroups/endpointgroup_delete.go | 2 +- .../endpointgroups/endpointgroup_inspect.go | 2 +- .../endpointgroups/endpointgroup_update.go | 2 +- .../endpointgroup_update_access.go | 2 +- api/http/handler/endpointproxy/proxy_azure.go | 2 +- .../handler/endpointproxy/proxy_docker.go | 2 +- .../handler/endpointproxy/proxy_storidge.go | 2 +- api/http/handler/endpoints/endpoint_delete.go | 2 +- .../endpoints/endpoint_extension_add.go | 2 +- .../endpoints/endpoint_extension_remove.go | 2 +- .../handler/endpoints/endpoint_inspect.go | 2 +- api/http/handler/endpoints/endpoint_update.go | 2 +- .../endpoints/endpoint_update_access.go | 2 +- .../handler/registries/registry_delete.go | 2 +- .../handler/registries/registry_inspect.go | 2 +- .../handler/registries/registry_update.go | 2 +- .../registries/registry_update_access.go | 2 +- .../resourcecontrol_create.go | 2 +- .../resourcecontrol_delete.go | 2 +- .../resourcecontrol_update.go | 2 +- api/http/handler/settings/settings_update.go | 2 +- api/http/handler/stacks/stack_create.go | 2 +- api/http/handler/stacks/stack_delete.go | 10 +- api/http/handler/stacks/stack_file.go | 4 +- api/http/handler/stacks/stack_inspect.go | 4 +- api/http/handler/stacks/stack_update.go | 6 +- .../teammemberships/teammembership_delete.go | 2 +- .../teammemberships/teammembership_update.go | 2 +- api/http/handler/teams/team_create.go | 2 +- api/http/handler/teams/team_delete.go | 2 +- api/http/handler/teams/team_inspect.go | 2 +- api/http/handler/teams/team_update.go | 2 +- api/http/handler/users/admin_check.go | 2 +- api/http/handler/users/user_create.go | 2 +- api/http/handler/users/user_delete.go | 2 +- api/http/handler/users/user_inspect.go | 2 +- api/http/handler/users/user_password.go | 2 +- api/http/handler/users/user_update.go | 2 +- api/http/handler/websocket/websocket_exec.go | 2 +- api/http/security/bouncer.go | 2 +- api/portainer.go | 5 +- 89 files changed, 1729 insertions(+), 1791 deletions(-) create mode 100644 api/bolt/dockerhub/dockerhub.go delete mode 100644 api/bolt/dockerhub_service.go create mode 100644 api/bolt/endpoint/endpoint.go delete mode 100644 api/bolt/endpoint_group_service.go delete mode 100644 api/bolt/endpoint_service.go create mode 100644 api/bolt/endpointgroup/endpointgroup.go create mode 100644 api/bolt/internal/db.go rename api/bolt/internal/{internal.go => json.go} (53%) delete mode 100644 api/bolt/migrate_dbversion4.go delete mode 100644 api/bolt/migrate_dbversion5.go delete mode 100644 api/bolt/migrate_dbversion6.go delete mode 100644 api/bolt/migrator.go rename api/bolt/{ => migrator}/migrate_dbversion0.go (56%) rename api/bolt/{ => migrator}/migrate_dbversion1.go (89%) rename api/bolt/{ => migrator}/migrate_dbversion10.go (78%) rename api/bolt/{ => migrator}/migrate_dbversion11.go (73%) rename api/bolt/{ => migrator}/migrate_dbversion2.go (70%) rename api/bolt/{ => migrator}/migrate_dbversion3.go (80%) create mode 100644 api/bolt/migrator/migrate_dbversion4.go create mode 100644 api/bolt/migrator/migrate_dbversion5.go create mode 100644 api/bolt/migrator/migrate_dbversion6.go rename api/bolt/{ => migrator}/migrate_dbversion7.go (67%) rename api/bolt/{ => migrator}/migrate_dbversion8.go (67%) rename api/bolt/{ => migrator}/migrate_dbversion9.go (67%) create mode 100644 api/bolt/migrator/migrator.go create mode 100644 api/bolt/registry/registry.go delete mode 100644 api/bolt/registry_service.go delete mode 100644 api/bolt/resource_control_service.go create mode 100644 api/bolt/resourcecontrol/resourcecontrol.go create mode 100644 api/bolt/settings/settings.go delete mode 100644 api/bolt/settings_service.go create mode 100644 api/bolt/stack/stack.go delete mode 100644 api/bolt/stack_service.go create mode 100644 api/bolt/tag/tag.go delete mode 100644 api/bolt/tag_service.go create mode 100644 api/bolt/team/team.go delete mode 100644 api/bolt/team_service.go rename api/bolt/{team_membership_service.go => teammembership/teammembership.go} (51%) create mode 100644 api/bolt/user/user.go delete mode 100644 api/bolt/user_service.go create mode 100644 api/bolt/version/version.go delete mode 100644 api/bolt/version_service.go diff --git a/api/bolt/datastore.go b/api/bolt/datastore.go index d5002a413..e898d4ec2 100644 --- a/api/bolt/datastore.go +++ b/api/bolt/datastore.go @@ -2,90 +2,66 @@ package bolt import ( "log" - "os" + "path" "time" "github.com/boltdb/bolt" "github.com/portainer/portainer" + "github.com/portainer/portainer/bolt/dockerhub" + "github.com/portainer/portainer/bolt/endpoint" + "github.com/portainer/portainer/bolt/endpointgroup" + "github.com/portainer/portainer/bolt/migrator" + "github.com/portainer/portainer/bolt/registry" + "github.com/portainer/portainer/bolt/resourcecontrol" + "github.com/portainer/portainer/bolt/settings" + "github.com/portainer/portainer/bolt/stack" + "github.com/portainer/portainer/bolt/tag" + "github.com/portainer/portainer/bolt/team" + "github.com/portainer/portainer/bolt/teammembership" + "github.com/portainer/portainer/bolt/user" + "github.com/portainer/portainer/bolt/version" +) + +const ( + databaseFileName = "portainer.db" ) // Store defines the implementation of portainer.DataStore using // BoltDB as the storage system. type Store struct { - // Path where is stored the BoltDB database. - Path string - - // Services - UserService *UserService - TeamService *TeamService - TeamMembershipService *TeamMembershipService - EndpointService *EndpointService - EndpointGroupService *EndpointGroupService - ResourceControlService *ResourceControlService - VersionService *VersionService - SettingsService *SettingsService - RegistryService *RegistryService - DockerHubService *DockerHubService - StackService *StackService - TagService *TagService - - db *bolt.DB - checkForDataMigration bool - FileService portainer.FileService + path string + db *bolt.DB + checkForDataMigration bool + fileService portainer.FileService + DockerHubService *dockerhub.Service + EndpointGroupService *endpointgroup.Service + EndpointService *endpoint.Service + RegistryService *registry.Service + ResourceControlService *resourcecontrol.Service + SettingsService *settings.Service + StackService *stack.Service + TagService *tag.Service + TeamMembershipService *teammembership.Service + TeamService *team.Service + UserService *user.Service + VersionService *version.Service } -const ( - databaseFileName = "portainer.db" - versionBucketName = "version" - userBucketName = "users" - teamBucketName = "teams" - teamMembershipBucketName = "team_membership" - endpointBucketName = "endpoints" - endpointGroupBucketName = "endpoint_groups" - resourceControlBucketName = "resource_control" - settingsBucketName = "settings" - registryBucketName = "registries" - dockerhubBucketName = "dockerhub" - stackBucketName = "stacks" - tagBucketName = "tags" -) - // NewStore initializes a new Store and the associated services func NewStore(storePath string, fileService portainer.FileService) (*Store, error) { store := &Store{ - Path: storePath, - UserService: &UserService{}, - TeamService: &TeamService{}, - TeamMembershipService: &TeamMembershipService{}, - EndpointService: &EndpointService{}, - EndpointGroupService: &EndpointGroupService{}, - ResourceControlService: &ResourceControlService{}, - VersionService: &VersionService{}, - SettingsService: &SettingsService{}, - RegistryService: &RegistryService{}, - DockerHubService: &DockerHubService{}, - StackService: &StackService{}, - TagService: &TagService{}, - FileService: fileService, + path: storePath, + fileService: fileService, } - store.UserService.store = store - store.TeamService.store = store - store.TeamMembershipService.store = store - store.EndpointService.store = store - store.EndpointGroupService.store = store - store.ResourceControlService.store = store - store.VersionService.store = store - store.SettingsService.store = store - store.RegistryService.store = store - store.DockerHubService.store = store - store.StackService.store = store - store.TagService.store = store - _, err := os.Stat(storePath + "/" + databaseFileName) - if err != nil && os.IsNotExist(err) { - store.checkForDataMigration = false - } else if err != nil { + databasePath := path.Join(storePath, databaseFileName) + databaseFileExists, err := fileService.FileExists(databasePath) + if err != nil { return nil, err + } + + if !databaseFileExists { + store.checkForDataMigration = false } else { store.checkForDataMigration = true } @@ -95,29 +71,14 @@ func NewStore(storePath string, fileService portainer.FileService) (*Store, erro // Open opens and initializes the BoltDB database. func (store *Store) Open() error { - path := store.Path + "/" + databaseFileName - - db, err := bolt.Open(path, 0600, &bolt.Options{Timeout: 1 * time.Second}) + databasePath := path.Join(store.path, databaseFileName) + db, err := bolt.Open(databasePath, 0600, &bolt.Options{Timeout: 1 * time.Second}) if err != nil { return err } store.db = db - bucketsToCreate := []string{versionBucketName, userBucketName, teamBucketName, endpointBucketName, - endpointGroupBucketName, resourceControlBucketName, teamMembershipBucketName, settingsBucketName, - registryBucketName, dockerhubBucketName, stackBucketName, tagBucketName} - - return db.Update(func(tx *bolt.Tx) error { - - for _, bucket := range bucketsToCreate { - _, err := tx.CreateBucketIfNotExists([]byte(bucket)) - if err != nil { - return err - } - } - - return nil - }) + return store.initServices() } // Init creates the default data set. @@ -154,28 +115,114 @@ func (store *Store) Close() error { // MigrateData automatically migrate the data based on the DBVersion. func (store *Store) MigrateData() error { if !store.checkForDataMigration { - err := store.VersionService.StoreDBVersion(portainer.DBVersion) - if err != nil { - return err - } - return nil + return store.VersionService.StoreDBVersion(portainer.DBVersion) } version, err := store.VersionService.DBVersion() - if err == portainer.ErrDBVersionNotFound { + if err == portainer.ErrObjectNotFound { version = 0 } else if err != nil { return err } if version < portainer.DBVersion { + migratorParams := &migrator.Parameters{ + DB: store.db, + DatabaseVersion: version, + EndpointGroupService: store.EndpointGroupService, + EndpointService: store.EndpointService, + ResourceControlService: store.ResourceControlService, + SettingsService: store.SettingsService, + StackService: store.StackService, + UserService: store.UserService, + VersionService: store.VersionService, + FileService: store.fileService, + } + migrator := migrator.NewMigrator(migratorParams) + log.Printf("Migrating database from version %v to %v.\n", version, portainer.DBVersion) - migrator := NewMigrator(store, version) err = migrator.Migrate() if err != nil { + log.Printf("An error occurred during database migration: %s\n", err) return err } } return nil } + +func (store *Store) initServices() error { + dockerhubService, err := dockerhub.NewService(store.db) + if err != nil { + return err + } + store.DockerHubService = dockerhubService + + endpointgroupService, err := endpointgroup.NewService(store.db) + if err != nil { + return err + } + store.EndpointGroupService = endpointgroupService + + endpointService, err := endpoint.NewService(store.db) + if err != nil { + return err + } + store.EndpointService = endpointService + + registryService, err := registry.NewService(store.db) + if err != nil { + return err + } + store.RegistryService = registryService + + resourcecontrolService, err := resourcecontrol.NewService(store.db) + if err != nil { + return err + } + store.ResourceControlService = resourcecontrolService + + settingsService, err := settings.NewService(store.db) + if err != nil { + return err + } + store.SettingsService = settingsService + + stackService, err := stack.NewService(store.db) + if err != nil { + return err + } + store.StackService = stackService + + tagService, err := tag.NewService(store.db) + if err != nil { + return err + } + store.TagService = tagService + + teammembershipService, err := teammembership.NewService(store.db) + if err != nil { + return err + } + store.TeamMembershipService = teammembershipService + + teamService, err := team.NewService(store.db) + if err != nil { + return err + } + store.TeamService = teamService + + userService, err := user.NewService(store.db) + if err != nil { + return err + } + store.UserService = userService + + versionService, err := version.NewService(store.db) + if err != nil { + return err + } + store.VersionService = versionService + + return nil +} diff --git a/api/bolt/dockerhub/dockerhub.go b/api/bolt/dockerhub/dockerhub.go new file mode 100644 index 000000000..0e4b4858c --- /dev/null +++ b/api/bolt/dockerhub/dockerhub.go @@ -0,0 +1,48 @@ +package dockerhub + +import ( + "github.com/portainer/portainer" + "github.com/portainer/portainer/bolt/internal" + + "github.com/boltdb/bolt" +) + +const ( + // BucketName represents the name of the bucket where this service stores data. + BucketName = "dockerhub" + dockerHubKey = "DOCKERHUB" +) + +// Service represents a service for managing Dockerhub data. +type Service struct { + db *bolt.DB +} + +// NewService creates a new instance of a service. +func NewService(db *bolt.DB) (*Service, error) { + err := internal.CreateBucket(db, BucketName) + if err != nil { + return nil, err + } + + return &Service{ + db: db, + }, nil +} + +// DockerHub returns the DockerHub object. +func (service *Service) DockerHub() (*portainer.DockerHub, error) { + var dockerhub portainer.DockerHub + + err := internal.GetObject(service.db, BucketName, []byte(dockerHubKey), &dockerhub) + if err != nil { + return nil, err + } + + return &dockerhub, nil +} + +// UpdateDockerHub updates a DockerHub object. +func (service *Service) UpdateDockerHub(dockerhub *portainer.DockerHub) error { + return internal.UpdateObject(service.db, BucketName, []byte(dockerHubKey), dockerhub) +} diff --git a/api/bolt/dockerhub_service.go b/api/bolt/dockerhub_service.go deleted file mode 100644 index d28aa6fb2..000000000 --- a/api/bolt/dockerhub_service.go +++ /dev/null @@ -1,61 +0,0 @@ -package bolt - -import ( - "github.com/portainer/portainer" - "github.com/portainer/portainer/bolt/internal" - - "github.com/boltdb/bolt" -) - -// DockerHubService represents a service for managing registries. -type DockerHubService struct { - store *Store -} - -const ( - dbDockerHubKey = "DOCKERHUB" -) - -// DockerHub returns the DockerHub object. -func (service *DockerHubService) DockerHub() (*portainer.DockerHub, error) { - var data []byte - err := service.store.db.View(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(dockerhubBucketName)) - value := bucket.Get([]byte(dbDockerHubKey)) - if value == nil { - return portainer.ErrDockerHubNotFound - } - - data = make([]byte, len(value)) - copy(data, value) - return nil - }) - if err != nil { - return nil, err - } - - var dockerhub portainer.DockerHub - err = internal.UnmarshalObject(data, &dockerhub) - if err != nil { - return nil, err - } - return &dockerhub, nil -} - -// StoreDockerHub persists a DockerHub object. -func (service *DockerHubService) StoreDockerHub(dockerhub *portainer.DockerHub) error { - return service.store.db.Update(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(dockerhubBucketName)) - - data, err := internal.MarshalObject(dockerhub) - if err != nil { - return err - } - - err = bucket.Put([]byte(dbDockerHubKey), data) - if err != nil { - return err - } - return nil - }) -} diff --git a/api/bolt/endpoint/endpoint.go b/api/bolt/endpoint/endpoint.go new file mode 100644 index 000000000..79672eb36 --- /dev/null +++ b/api/bolt/endpoint/endpoint.go @@ -0,0 +1,138 @@ +package endpoint + +import ( + "github.com/portainer/portainer" + "github.com/portainer/portainer/bolt/internal" + + "github.com/boltdb/bolt" +) + +const ( + // BucketName represents the name of the bucket where this service stores data. + BucketName = "endpoints" +) + +// Service represents a service for managing endpoint data. +type Service struct { + db *bolt.DB +} + +// NewService creates a new instance of a service. +func NewService(db *bolt.DB) (*Service, error) { + err := internal.CreateBucket(db, BucketName) + if err != nil { + return nil, err + } + + return &Service{ + db: db, + }, nil +} + +// Endpoint returns an endpoint by ID. +func (service *Service) Endpoint(ID portainer.EndpointID) (*portainer.Endpoint, error) { + var endpoint portainer.Endpoint + identifier := internal.Itob(int(ID)) + + err := internal.GetObject(service.db, BucketName, identifier, &endpoint) + if err != nil { + return nil, err + } + + return &endpoint, nil +} + +// UpdateEndpoint updates an endpoint. +func (service *Service) UpdateEndpoint(ID portainer.EndpointID, endpoint *portainer.Endpoint) error { + identifier := internal.Itob(int(ID)) + return internal.UpdateObject(service.db, BucketName, identifier, endpoint) +} + +// DeleteEndpoint deletes an endpoint. +func (service *Service) DeleteEndpoint(ID portainer.EndpointID) error { + identifier := internal.Itob(int(ID)) + return internal.DeleteObject(service.db, BucketName, identifier) +} + +// Endpoints return an array containing all the endpoints. +func (service *Service) Endpoints() ([]portainer.Endpoint, error) { + var endpoints = make([]portainer.Endpoint, 0) + + err := service.db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(BucketName)) + + cursor := bucket.Cursor() + for k, v := cursor.First(); k != nil; k, v = cursor.Next() { + var endpoint portainer.Endpoint + err := internal.UnmarshalObject(v, &endpoint) + if err != nil { + return err + } + endpoints = append(endpoints, endpoint) + } + + return nil + }) + + return endpoints, err +} + +// CreateEndpoint assign an ID to a new endpoint and saves it. +func (service *Service) CreateEndpoint(endpoint *portainer.Endpoint) error { + return service.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(BucketName)) + + id, _ := bucket.NextSequence() + endpoint.ID = portainer.EndpointID(id) + + data, err := internal.MarshalObject(endpoint) + if err != nil { + return err + } + + return bucket.Put(internal.Itob(int(endpoint.ID)), data) + }) +} + +// Synchronize creates, updates and deletes endpoints inside a single transaction. +func (service *Service) Synchronize(toCreate, toUpdate, toDelete []*portainer.Endpoint) error { + return service.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(BucketName)) + + for _, endpoint := range toCreate { + id, _ := bucket.NextSequence() + endpoint.ID = portainer.EndpointID(id) + + data, err := internal.MarshalObject(endpoint) + if err != nil { + return err + } + + err = bucket.Put(internal.Itob(int(endpoint.ID)), data) + if err != nil { + return err + } + } + + for _, endpoint := range toUpdate { + data, err := internal.MarshalObject(endpoint) + if err != nil { + return err + } + + err = bucket.Put(internal.Itob(int(endpoint.ID)), data) + if err != nil { + return err + } + } + + for _, endpoint := range toDelete { + err := bucket.Delete(internal.Itob(int(endpoint.ID))) + if err != nil { + return err + } + } + + return nil + }) +} diff --git a/api/bolt/endpoint_group_service.go b/api/bolt/endpoint_group_service.go deleted file mode 100644 index 9a539ee83..000000000 --- a/api/bolt/endpoint_group_service.go +++ /dev/null @@ -1,114 +0,0 @@ -package bolt - -import ( - "github.com/portainer/portainer" - "github.com/portainer/portainer/bolt/internal" - - "github.com/boltdb/bolt" -) - -// EndpointGroupService represents a service for managing endpoint groups. -type EndpointGroupService struct { - store *Store -} - -// EndpointGroup returns an endpoint group by ID. -func (service *EndpointGroupService) EndpointGroup(ID portainer.EndpointGroupID) (*portainer.EndpointGroup, error) { - var data []byte - err := service.store.db.View(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(endpointGroupBucketName)) - value := bucket.Get(internal.Itob(int(ID))) - if value == nil { - return portainer.ErrEndpointGroupNotFound - } - - data = make([]byte, len(value)) - copy(data, value) - return nil - }) - if err != nil { - return nil, err - } - - var endpointGroup portainer.EndpointGroup - err = internal.UnmarshalObject(data, &endpointGroup) - if err != nil { - return nil, err - } - return &endpointGroup, nil -} - -// EndpointGroups return an array containing all the endpoint groups. -func (service *EndpointGroupService) EndpointGroups() ([]portainer.EndpointGroup, error) { - var endpointGroups = make([]portainer.EndpointGroup, 0) - err := service.store.db.View(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(endpointGroupBucketName)) - - cursor := bucket.Cursor() - for k, v := cursor.First(); k != nil; k, v = cursor.Next() { - var endpointGroup portainer.EndpointGroup - err := internal.UnmarshalObject(v, &endpointGroup) - if err != nil { - return err - } - endpointGroups = append(endpointGroups, endpointGroup) - } - - return nil - }) - if err != nil { - return nil, err - } - - return endpointGroups, nil -} - -// CreateEndpointGroup assign an ID to a new endpoint group and saves it. -func (service *EndpointGroupService) CreateEndpointGroup(endpointGroup *portainer.EndpointGroup) error { - return service.store.db.Update(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(endpointGroupBucketName)) - - id, _ := bucket.NextSequence() - endpointGroup.ID = portainer.EndpointGroupID(id) - - data, err := internal.MarshalObject(endpointGroup) - if err != nil { - return err - } - - err = bucket.Put(internal.Itob(int(endpointGroup.ID)), data) - if err != nil { - return err - } - return nil - }) -} - -// UpdateEndpointGroup updates an endpoint group. -func (service *EndpointGroupService) UpdateEndpointGroup(ID portainer.EndpointGroupID, endpointGroup *portainer.EndpointGroup) error { - data, err := internal.MarshalObject(endpointGroup) - if err != nil { - return err - } - - return service.store.db.Update(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(endpointGroupBucketName)) - err = bucket.Put(internal.Itob(int(ID)), data) - if err != nil { - return err - } - return nil - }) -} - -// DeleteEndpointGroup deletes an endpoint group. -func (service *EndpointGroupService) DeleteEndpointGroup(ID portainer.EndpointGroupID) error { - return service.store.db.Update(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(endpointGroupBucketName)) - err := bucket.Delete(internal.Itob(int(ID))) - if err != nil { - return err - } - return nil - }) -} diff --git a/api/bolt/endpoint_service.go b/api/bolt/endpoint_service.go deleted file mode 100644 index e685a9b72..000000000 --- a/api/bolt/endpoint_service.go +++ /dev/null @@ -1,154 +0,0 @@ -package bolt - -import ( - "github.com/portainer/portainer" - "github.com/portainer/portainer/bolt/internal" - - "github.com/boltdb/bolt" -) - -// EndpointService represents a service for managing endpoints. -type EndpointService struct { - store *Store -} - -// Endpoint returns an endpoint by ID. -func (service *EndpointService) Endpoint(ID portainer.EndpointID) (*portainer.Endpoint, error) { - var data []byte - err := service.store.db.View(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(endpointBucketName)) - value := bucket.Get(internal.Itob(int(ID))) - if value == nil { - return portainer.ErrEndpointNotFound - } - - data = make([]byte, len(value)) - copy(data, value) - return nil - }) - if err != nil { - return nil, err - } - - var endpoint portainer.Endpoint - err = internal.UnmarshalObject(data, &endpoint) - if err != nil { - return nil, err - } - return &endpoint, nil -} - -// Endpoints return an array containing all the endpoints. -func (service *EndpointService) Endpoints() ([]portainer.Endpoint, error) { - var endpoints = make([]portainer.Endpoint, 0) - err := service.store.db.View(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(endpointBucketName)) - - cursor := bucket.Cursor() - for k, v := cursor.First(); k != nil; k, v = cursor.Next() { - var endpoint portainer.Endpoint - err := internal.UnmarshalObject(v, &endpoint) - if err != nil { - return err - } - endpoints = append(endpoints, endpoint) - } - - return nil - }) - if err != nil { - return nil, err - } - - return endpoints, nil -} - -// Synchronize creates, updates and deletes endpoints inside a single transaction. -func (service *EndpointService) Synchronize(toCreate, toUpdate, toDelete []*portainer.Endpoint) error { - return service.store.db.Update(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(endpointBucketName)) - - for _, endpoint := range toCreate { - err := storeNewEndpoint(endpoint, bucket) - if err != nil { - return err - } - } - - for _, endpoint := range toUpdate { - err := marshalAndStoreEndpoint(endpoint, bucket) - if err != nil { - return err - } - } - - for _, endpoint := range toDelete { - err := bucket.Delete(internal.Itob(int(endpoint.ID))) - if err != nil { - return err - } - } - - return nil - }) -} - -// CreateEndpoint assign an ID to a new endpoint and saves it. -func (service *EndpointService) CreateEndpoint(endpoint *portainer.Endpoint) error { - return service.store.db.Update(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(endpointBucketName)) - err := storeNewEndpoint(endpoint, bucket) - if err != nil { - return err - } - return nil - }) -} - -// UpdateEndpoint updates an endpoint. -func (service *EndpointService) UpdateEndpoint(ID portainer.EndpointID, endpoint *portainer.Endpoint) error { - data, err := internal.MarshalObject(endpoint) - if err != nil { - return err - } - - return service.store.db.Update(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(endpointBucketName)) - err = bucket.Put(internal.Itob(int(ID)), data) - if err != nil { - return err - } - return nil - }) -} - -// DeleteEndpoint deletes an endpoint. -func (service *EndpointService) DeleteEndpoint(ID portainer.EndpointID) error { - return service.store.db.Update(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(endpointBucketName)) - err := bucket.Delete(internal.Itob(int(ID))) - if err != nil { - return err - } - return nil - }) -} - -func marshalAndStoreEndpoint(endpoint *portainer.Endpoint, bucket *bolt.Bucket) error { - data, err := internal.MarshalObject(endpoint) - if err != nil { - return err - } - - err = bucket.Put(internal.Itob(int(endpoint.ID)), data) - if err != nil { - return err - } - return nil -} - -func storeNewEndpoint(endpoint *portainer.Endpoint, bucket *bolt.Bucket) error { - id, _ := bucket.NextSequence() - endpoint.ID = portainer.EndpointID(id) - return marshalAndStoreEndpoint(endpoint, bucket) -} diff --git a/api/bolt/endpointgroup/endpointgroup.go b/api/bolt/endpointgroup/endpointgroup.go new file mode 100644 index 000000000..89398dbd5 --- /dev/null +++ b/api/bolt/endpointgroup/endpointgroup.go @@ -0,0 +1,95 @@ +package endpointgroup + +import ( + "github.com/portainer/portainer" + "github.com/portainer/portainer/bolt/internal" + + "github.com/boltdb/bolt" +) + +const ( + // BucketName represents the name of the bucket where this service stores data. + BucketName = "endpoint_groups" +) + +// Service represents a service for managing endpoint data. +type Service struct { + db *bolt.DB +} + +// NewService creates a new instance of a service. +func NewService(db *bolt.DB) (*Service, error) { + err := internal.CreateBucket(db, BucketName) + if err != nil { + return nil, err + } + + return &Service{ + db: db, + }, nil +} + +// EndpointGroup returns an endpoint group by ID. +func (service *Service) EndpointGroup(ID portainer.EndpointGroupID) (*portainer.EndpointGroup, error) { + var endpointGroup portainer.EndpointGroup + identifier := internal.Itob(int(ID)) + + err := internal.GetObject(service.db, BucketName, identifier, &endpointGroup) + if err != nil { + return nil, err + } + + return &endpointGroup, nil +} + +// UpdateEndpointGroup updates an endpoint group. +func (service *Service) UpdateEndpointGroup(ID portainer.EndpointGroupID, endpointGroup *portainer.EndpointGroup) error { + identifier := internal.Itob(int(ID)) + return internal.UpdateObject(service.db, BucketName, identifier, endpointGroup) +} + +// DeleteEndpointGroup deletes an endpoint group. +func (service *Service) DeleteEndpointGroup(ID portainer.EndpointGroupID) error { + identifier := internal.Itob(int(ID)) + return internal.DeleteObject(service.db, BucketName, identifier) +} + +// EndpointGroups return an array containing all the endpoint groups. +func (service *Service) EndpointGroups() ([]portainer.EndpointGroup, error) { + var endpointGroups = make([]portainer.EndpointGroup, 0) + + err := service.db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(BucketName)) + + cursor := bucket.Cursor() + for k, v := cursor.First(); k != nil; k, v = cursor.Next() { + var endpointGroup portainer.EndpointGroup + err := internal.UnmarshalObject(v, &endpointGroup) + if err != nil { + return err + } + endpointGroups = append(endpointGroups, endpointGroup) + } + + return nil + }) + + return endpointGroups, err +} + +// CreateEndpointGroup assign an ID to a new endpoint group and saves it. +func (service *Service) CreateEndpointGroup(endpointGroup *portainer.EndpointGroup) error { + return service.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(BucketName)) + + id, _ := bucket.NextSequence() + endpointGroup.ID = portainer.EndpointGroupID(id) + + data, err := internal.MarshalObject(endpointGroup) + if err != nil { + return err + } + + return bucket.Put(internal.Itob(int(endpointGroup.ID)), data) + }) +} diff --git a/api/bolt/internal/db.go b/api/bolt/internal/db.go new file mode 100644 index 000000000..59ca7c87d --- /dev/null +++ b/api/bolt/internal/db.go @@ -0,0 +1,94 @@ +package internal + +import ( + "encoding/binary" + + "github.com/boltdb/bolt" + "github.com/portainer/portainer" +) + +// Itob returns an 8-byte big endian representation of v. +// This function is typically used for encoding integer IDs to byte slices +// so that they can be used as BoltDB keys. +func Itob(v int) []byte { + b := make([]byte, 8) + binary.BigEndian.PutUint64(b, uint64(v)) + return b +} + +// CreateBucket is a generic function used to create a bucket inside a bolt database. +func CreateBucket(db *bolt.DB, bucketName string) error { + return db.Update(func(tx *bolt.Tx) error { + _, err := tx.CreateBucketIfNotExists([]byte(bucketName)) + if err != nil { + return err + } + return nil + }) +} + +// GetObject is a generic function used to retrieve an unmarshalled object from a bolt database. +func GetObject(db *bolt.DB, bucketName string, key []byte, object interface{}) error { + var data []byte + + err := db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(bucketName)) + + value := bucket.Get(key) + if value == nil { + return portainer.ErrObjectNotFound + } + + data = make([]byte, len(value)) + copy(data, value) + + return nil + }) + if err != nil { + return err + } + + return UnmarshalObject(data, object) +} + +// UpdateObject is a generic function used to update an object inside a bolt database. +func UpdateObject(db *bolt.DB, bucketName string, key []byte, object interface{}) error { + return db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(bucketName)) + + data, err := MarshalObject(object) + if err != nil { + return err + } + + err = bucket.Put(key, data) + if err != nil { + return err + } + + return nil + }) +} + +// DeleteObject is a generic function used to delete an object inside a bolt database. +func DeleteObject(db *bolt.DB, bucketName string, key []byte) error { + return db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(bucketName)) + return bucket.Delete(key) + }) +} + +// GetNextIdentifier is a generic function that returns the specified bucket identifier incremented by 1. +func GetNextIdentifier(db *bolt.DB, bucketName string) int { + var identifier int + + db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(bucketName)) + id := bucket.Sequence() + identifier = int(id) + return nil + }) + + identifier++ + return identifier +} diff --git a/api/bolt/internal/internal.go b/api/bolt/internal/json.go similarity index 53% rename from api/bolt/internal/internal.go rename to api/bolt/internal/json.go index 4c45524b6..9f69f06ee 100644 --- a/api/bolt/internal/internal.go +++ b/api/bolt/internal/json.go @@ -1,7 +1,6 @@ package internal import ( - "encoding/binary" "encoding/json" ) @@ -14,12 +13,3 @@ func MarshalObject(object interface{}) ([]byte, error) { func UnmarshalObject(data []byte, object interface{}) error { return json.Unmarshal(data, object) } - -// Itob returns an 8-byte big endian representation of v. -// This function is typically used for encoding integer IDs to byte slices -// so that they can be used as BoltDB keys. -func Itob(v int) []byte { - b := make([]byte, 8) - binary.BigEndian.PutUint64(b, uint64(v)) - return b -} diff --git a/api/bolt/migrate_dbversion4.go b/api/bolt/migrate_dbversion4.go deleted file mode 100644 index ace64f51e..000000000 --- a/api/bolt/migrate_dbversion4.go +++ /dev/null @@ -1,16 +0,0 @@ -package bolt - -func (m *Migrator) updateSettingsToVersion5() error { - legacySettings, err := m.SettingsService.Settings() - if err != nil { - return err - } - legacySettings.AllowBindMountsForRegularUsers = true - - err = m.SettingsService.StoreSettings(legacySettings) - if err != nil { - return err - } - - return nil -} diff --git a/api/bolt/migrate_dbversion5.go b/api/bolt/migrate_dbversion5.go deleted file mode 100644 index ef1bfd3ad..000000000 --- a/api/bolt/migrate_dbversion5.go +++ /dev/null @@ -1,16 +0,0 @@ -package bolt - -func (m *Migrator) updateSettingsToVersion6() error { - legacySettings, err := m.SettingsService.Settings() - if err != nil { - return err - } - legacySettings.AllowPrivilegedModeForRegularUsers = true - - err = m.SettingsService.StoreSettings(legacySettings) - if err != nil { - return err - } - - return nil -} diff --git a/api/bolt/migrate_dbversion6.go b/api/bolt/migrate_dbversion6.go deleted file mode 100644 index 95d53af61..000000000 --- a/api/bolt/migrate_dbversion6.go +++ /dev/null @@ -1,16 +0,0 @@ -package bolt - -func (m *Migrator) updateSettingsToVersion7() error { - legacySettings, err := m.SettingsService.Settings() - if err != nil { - return err - } - legacySettings.DisplayDonationHeader = true - - err = m.SettingsService.StoreSettings(legacySettings) - if err != nil { - return err - } - - return nil -} diff --git a/api/bolt/migrator.go b/api/bolt/migrator.go deleted file mode 100644 index 84ede8156..000000000 --- a/api/bolt/migrator.go +++ /dev/null @@ -1,152 +0,0 @@ -package bolt - -import "github.com/portainer/portainer" - -// Migrator defines a service to migrate data after a Portainer version update. -type Migrator struct { - CurrentDBVersion int - store *Store - - EndpointGroupService *EndpointGroupService - EndpointService *EndpointService - ResourceControlService *ResourceControlService - SettingsService *SettingsService - StackService *StackService - UserService *UserService - VersionService *VersionService - FileService portainer.FileService -} - -// NewMigrator creates a new Migrator. -func NewMigrator(store *Store, version int) *Migrator { - return &Migrator{ - CurrentDBVersion: version, - store: store, - EndpointGroupService: store.EndpointGroupService, - EndpointService: store.EndpointService, - ResourceControlService: store.ResourceControlService, - SettingsService: store.SettingsService, - StackService: store.StackService, - UserService: store.UserService, - VersionService: store.VersionService, - } -} - -// Migrate checks the database version and migrate the existing data to the most recent data model. -func (m *Migrator) Migrate() error { - - // Portainer < 1.12 - if m.CurrentDBVersion < 1 { - err := m.updateAdminUserToDBVersion1() - if err != nil { - return err - } - } - - // Portainer 1.12.x - if m.CurrentDBVersion < 2 { - err := m.updateResourceControlsToDBVersion2() - if err != nil { - return err - } - err = m.updateEndpointsToDBVersion2() - if err != nil { - return err - } - } - - // Portainer 1.13.x - if m.CurrentDBVersion < 3 { - err := m.updateSettingsToDBVersion3() - if err != nil { - return err - } - } - - // Portainer 1.14.0 - if m.CurrentDBVersion < 4 { - err := m.updateEndpointsToDBVersion4() - if err != nil { - return err - } - } - - // https://github.com/portainer/portainer/issues/1235 - if m.CurrentDBVersion < 5 { - err := m.updateSettingsToVersion5() - if err != nil { - return err - } - } - - // https://github.com/portainer/portainer/issues/1236 - if m.CurrentDBVersion < 6 { - err := m.updateSettingsToVersion6() - if err != nil { - return err - } - } - - // https://github.com/portainer/portainer/issues/1449 - if m.CurrentDBVersion < 7 { - err := m.updateSettingsToVersion7() - if err != nil { - return err - } - } - - if m.CurrentDBVersion < 8 { - err := m.updateEndpointsToVersion8() - if err != nil { - return err - } - } - - // https: //github.com/portainer/portainer/issues/1396 - if m.CurrentDBVersion < 9 { - err := m.updateEndpointsToVersion9() - if err != nil { - return err - } - } - - // https://github.com/portainer/portainer/issues/461 - if m.CurrentDBVersion < 10 { - err := m.updateEndpointsToVersion10() - if err != nil { - return err - } - } - - // https://github.com/portainer/portainer/issues/1906 - if m.CurrentDBVersion < 11 { - err := m.updateEndpointsToVersion11() - if err != nil { - return err - } - } - - // Portainer 1.17.1-dev - if m.CurrentDBVersion < 12 { - err := m.updateEndpointsToVersion12() - if err != nil { - return err - } - - err = m.updateEndpointGroupsToVersion12() - if err != nil { - return err - } - - err = m.updateStacksToVersion12() - if err != nil { - return err - } - } - - err := m.VersionService.StoreDBVersion(portainer.DBVersion) - if err != nil { - return err - } - return nil -} diff --git a/api/bolt/migrate_dbversion0.go b/api/bolt/migrator/migrate_dbversion0.go similarity index 56% rename from api/bolt/migrate_dbversion0.go rename to api/bolt/migrator/migrate_dbversion0.go index f0223ee9e..4c2bdff12 100644 --- a/api/bolt/migrate_dbversion0.go +++ b/api/bolt/migrator/migrate_dbversion0.go @@ -1,19 +1,20 @@ -package bolt +package migrator import ( "github.com/boltdb/bolt" "github.com/portainer/portainer" + "github.com/portainer/portainer/bolt/user" ) func (m *Migrator) updateAdminUserToDBVersion1() error { - u, err := m.UserService.UserByUsername("admin") + u, err := m.userService.UserByUsername("admin") if err == nil { admin := &portainer.User{ Username: "admin", Password: u.Password, Role: portainer.AdministratorRole, } - err = m.UserService.CreateUser(admin) + err = m.userService.CreateUser(admin) if err != nil { return err } @@ -21,19 +22,15 @@ func (m *Migrator) updateAdminUserToDBVersion1() error { if err != nil { return err } - } else if err != nil && err != portainer.ErrUserNotFound { + } else if err != nil && err != portainer.ErrObjectNotFound { return err } return nil } func (m *Migrator) removeLegacyAdminUser() error { - return m.store.db.Update(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(userBucketName)) - err := bucket.Delete([]byte("admin")) - if err != nil { - return err - } - return nil + return m.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(user.BucketName)) + return bucket.Delete([]byte("admin")) }) } diff --git a/api/bolt/migrate_dbversion1.go b/api/bolt/migrator/migrate_dbversion1.go similarity index 89% rename from api/bolt/migrate_dbversion1.go rename to api/bolt/migrator/migrate_dbversion1.go index 0b255cd68..063f58dc7 100644 --- a/api/bolt/migrate_dbversion1.go +++ b/api/bolt/migrator/migrate_dbversion1.go @@ -1,4 +1,4 @@ -package bolt +package migrator import ( "github.com/boltdb/bolt" @@ -16,7 +16,7 @@ func (m *Migrator) updateResourceControlsToDBVersion2() error { resourceControl.SubResourceIDs = []string{} resourceControl.TeamAccesses = []portainer.TeamResourceAccess{} - owner, err := m.UserService.User(resourceControl.OwnerID) + owner, err := m.userService.User(resourceControl.OwnerID) if err != nil { return err } @@ -33,7 +33,7 @@ func (m *Migrator) updateResourceControlsToDBVersion2() error { resourceControl.UserAccesses = []portainer.UserResourceAccess{userAccess} } - err = m.ResourceControlService.CreateResourceControl(&resourceControl) + err = m.resourceControlService.CreateResourceControl(&resourceControl) if err != nil { return err } @@ -43,14 +43,14 @@ func (m *Migrator) updateResourceControlsToDBVersion2() error { } func (m *Migrator) updateEndpointsToDBVersion2() error { - legacyEndpoints, err := m.EndpointService.Endpoints() + legacyEndpoints, err := m.endpointService.Endpoints() if err != nil { return err } for _, endpoint := range legacyEndpoints { endpoint.AuthorizedTeams = []portainer.TeamID{} - err = m.EndpointService.UpdateEndpoint(endpoint.ID, &endpoint) + err = m.endpointService.UpdateEndpoint(endpoint.ID, &endpoint) if err != nil { return err } @@ -61,7 +61,7 @@ func (m *Migrator) updateEndpointsToDBVersion2() error { func (m *Migrator) retrieveLegacyResourceControls() ([]portainer.ResourceControl, error) { legacyResourceControls := make([]portainer.ResourceControl, 0) - err := m.store.db.View(func(tx *bolt.Tx) error { + err := m.db.View(func(tx *bolt.Tx) error { bucket := tx.Bucket([]byte("containerResourceControl")) cursor := bucket.Cursor() for k, v := cursor.First(); k != nil; k, v = cursor.Next() { diff --git a/api/bolt/migrate_dbversion10.go b/api/bolt/migrator/migrate_dbversion10.go similarity index 78% rename from api/bolt/migrate_dbversion10.go rename to api/bolt/migrator/migrate_dbversion10.go index 211d2497a..da55e3962 100644 --- a/api/bolt/migrate_dbversion10.go +++ b/api/bolt/migrator/migrate_dbversion10.go @@ -1,9 +1,9 @@ -package bolt +package migrator import "github.com/portainer/portainer" func (m *Migrator) updateEndpointsToVersion11() error { - legacyEndpoints, err := m.EndpointService.Endpoints() + legacyEndpoints, err := m.endpointService.Endpoints() if err != nil { return err } @@ -18,7 +18,7 @@ func (m *Migrator) updateEndpointsToVersion11() error { } } - err = m.EndpointService.UpdateEndpoint(endpoint.ID, &endpoint) + err = m.endpointService.UpdateEndpoint(endpoint.ID, &endpoint) if err != nil { return err } diff --git a/api/bolt/migrate_dbversion11.go b/api/bolt/migrator/migrate_dbversion11.go similarity index 73% rename from api/bolt/migrate_dbversion11.go rename to api/bolt/migrator/migrate_dbversion11.go index b53e3fcbf..168d23984 100644 --- a/api/bolt/migrate_dbversion11.go +++ b/api/bolt/migrator/migrate_dbversion11.go @@ -1,4 +1,4 @@ -package bolt +package migrator import ( "strconv" @@ -7,10 +7,11 @@ import ( "github.com/boltdb/bolt" "github.com/portainer/portainer" "github.com/portainer/portainer/bolt/internal" + "github.com/portainer/portainer/bolt/stack" ) func (m *Migrator) updateEndpointsToVersion12() error { - legacyEndpoints, err := m.EndpointService.Endpoints() + legacyEndpoints, err := m.endpointService.Endpoints() if err != nil { return err } @@ -18,7 +19,7 @@ func (m *Migrator) updateEndpointsToVersion12() error { for _, endpoint := range legacyEndpoints { endpoint.Tags = []string{} - err = m.EndpointService.UpdateEndpoint(endpoint.ID, &endpoint) + err = m.endpointService.UpdateEndpoint(endpoint.ID, &endpoint) if err != nil { return err } @@ -28,7 +29,7 @@ func (m *Migrator) updateEndpointsToVersion12() error { } func (m *Migrator) updateEndpointGroupsToVersion12() error { - legacyEndpointGroups, err := m.EndpointGroupService.EndpointGroups() + legacyEndpointGroups, err := m.endpointGroupService.EndpointGroups() if err != nil { return err } @@ -36,7 +37,7 @@ func (m *Migrator) updateEndpointGroupsToVersion12() error { for _, group := range legacyEndpointGroups { group.Tags = []string{} - err = m.EndpointGroupService.UpdateEndpointGroup(group.ID, &group) + err = m.endpointGroupService.UpdateEndpointGroup(group.ID, &group) if err != nil { return err } @@ -72,7 +73,7 @@ func (m *Migrator) updateStacksToVersion12() error { } func (m *Migrator) convertLegacyStack(s *legacyStack) error { - stackID := m.StackService.GetNextIdentifier() + stackID := m.stackService.GetNextIdentifier() stack := &portainer.Stack{ ID: portainer.StackID(stackID), Name: s.Name, @@ -84,7 +85,7 @@ func (m *Migrator) convertLegacyStack(s *legacyStack) error { } stack.ProjectPath = strings.Replace(s.ProjectPath, s.ID, strconv.Itoa(stackID), 1) - err := m.store.FileService.Rename(s.ProjectPath, stack.ProjectPath) + err := m.fileService.Rename(s.ProjectPath, stack.ProjectPath) if err != nil { return err } @@ -94,24 +95,20 @@ func (m *Migrator) convertLegacyStack(s *legacyStack) error { return err } - return m.StackService.CreateStack(stack) + return m.stackService.CreateStack(stack) } func (m *Migrator) deleteLegacyStack(legacyID string) error { - return m.store.db.Update(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(stackBucketName)) - err := bucket.Delete([]byte(legacyID)) - if err != nil { - return err - } - return nil + return m.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(stack.BucketName)) + return bucket.Delete([]byte(legacyID)) }) } func (m *Migrator) retrieveLegacyStacks() ([]legacyStack, error) { var legacyStacks = make([]legacyStack, 0) - err := m.store.db.View(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(stackBucketName)) + err := m.db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(stack.BucketName)) cursor := bucket.Cursor() for k, v := cursor.First(); k != nil; k, v = cursor.Next() { @@ -125,9 +122,6 @@ func (m *Migrator) retrieveLegacyStacks() ([]legacyStack, error) { return nil }) - if err != nil { - return nil, err - } - return legacyStacks, nil + return legacyStacks, err } diff --git a/api/bolt/migrate_dbversion2.go b/api/bolt/migrator/migrate_dbversion2.go similarity index 70% rename from api/bolt/migrate_dbversion2.go rename to api/bolt/migrator/migrate_dbversion2.go index 38a3e4b50..9488f50f9 100644 --- a/api/bolt/migrate_dbversion2.go +++ b/api/bolt/migrator/migrate_dbversion2.go @@ -1,9 +1,9 @@ -package bolt +package migrator import "github.com/portainer/portainer" func (m *Migrator) updateSettingsToDBVersion3() error { - legacySettings, err := m.SettingsService.Settings() + legacySettings, err := m.settingsService.Settings() if err != nil { return err } @@ -16,10 +16,5 @@ func (m *Migrator) updateSettingsToDBVersion3() error { }, } - err = m.SettingsService.StoreSettings(legacySettings) - if err != nil { - return err - } - - return nil + return m.settingsService.UpdateSettings(legacySettings) } diff --git a/api/bolt/migrate_dbversion3.go b/api/bolt/migrator/migrate_dbversion3.go similarity index 80% rename from api/bolt/migrate_dbversion3.go rename to api/bolt/migrator/migrate_dbversion3.go index d8679ca68..75636dc97 100644 --- a/api/bolt/migrate_dbversion3.go +++ b/api/bolt/migrator/migrate_dbversion3.go @@ -1,9 +1,9 @@ -package bolt +package migrator import "github.com/portainer/portainer" func (m *Migrator) updateEndpointsToDBVersion4() error { - legacyEndpoints, err := m.EndpointService.Endpoints() + legacyEndpoints, err := m.endpointService.Endpoints() if err != nil { return err } @@ -17,7 +17,8 @@ func (m *Migrator) updateEndpointsToDBVersion4() error { endpoint.TLSConfig.TLSCertPath = endpoint.TLSCertPath endpoint.TLSConfig.TLSKeyPath = endpoint.TLSKeyPath } - err = m.EndpointService.UpdateEndpoint(endpoint.ID, &endpoint) + + err = m.endpointService.UpdateEndpoint(endpoint.ID, &endpoint) if err != nil { return err } diff --git a/api/bolt/migrator/migrate_dbversion4.go b/api/bolt/migrator/migrate_dbversion4.go new file mode 100644 index 000000000..0bc7c84e4 --- /dev/null +++ b/api/bolt/migrator/migrate_dbversion4.go @@ -0,0 +1,11 @@ +package migrator + +func (m *Migrator) updateSettingsToVersion5() error { + legacySettings, err := m.settingsService.Settings() + if err != nil { + return err + } + + legacySettings.AllowBindMountsForRegularUsers = true + return m.settingsService.UpdateSettings(legacySettings) +} diff --git a/api/bolt/migrator/migrate_dbversion5.go b/api/bolt/migrator/migrate_dbversion5.go new file mode 100644 index 000000000..f1ccb5734 --- /dev/null +++ b/api/bolt/migrator/migrate_dbversion5.go @@ -0,0 +1,11 @@ +package migrator + +func (m *Migrator) updateSettingsToVersion6() error { + legacySettings, err := m.settingsService.Settings() + if err != nil { + return err + } + + legacySettings.AllowPrivilegedModeForRegularUsers = true + return m.settingsService.UpdateSettings(legacySettings) +} diff --git a/api/bolt/migrator/migrate_dbversion6.go b/api/bolt/migrator/migrate_dbversion6.go new file mode 100644 index 000000000..860a56ff0 --- /dev/null +++ b/api/bolt/migrator/migrate_dbversion6.go @@ -0,0 +1,11 @@ +package migrator + +func (m *Migrator) updateSettingsToVersion7() error { + legacySettings, err := m.settingsService.Settings() + if err != nil { + return err + } + legacySettings.DisplayDonationHeader = true + + return m.settingsService.UpdateSettings(legacySettings) +} diff --git a/api/bolt/migrate_dbversion7.go b/api/bolt/migrator/migrate_dbversion7.go similarity index 67% rename from api/bolt/migrate_dbversion7.go rename to api/bolt/migrator/migrate_dbversion7.go index bcdd199f2..b248e9e42 100644 --- a/api/bolt/migrate_dbversion7.go +++ b/api/bolt/migrator/migrate_dbversion7.go @@ -1,16 +1,16 @@ -package bolt +package migrator import "github.com/portainer/portainer" func (m *Migrator) updateEndpointsToVersion8() error { - legacyEndpoints, err := m.EndpointService.Endpoints() + legacyEndpoints, err := m.endpointService.Endpoints() if err != nil { return err } for _, endpoint := range legacyEndpoints { endpoint.Extensions = []portainer.EndpointExtension{} - err = m.EndpointService.UpdateEndpoint(endpoint.ID, &endpoint) + err = m.endpointService.UpdateEndpoint(endpoint.ID, &endpoint) if err != nil { return err } diff --git a/api/bolt/migrate_dbversion8.go b/api/bolt/migrator/migrate_dbversion8.go similarity index 67% rename from api/bolt/migrate_dbversion8.go rename to api/bolt/migrator/migrate_dbversion8.go index 7ef77806d..99a73bf11 100644 --- a/api/bolt/migrate_dbversion8.go +++ b/api/bolt/migrator/migrate_dbversion8.go @@ -1,16 +1,16 @@ -package bolt +package migrator import "github.com/portainer/portainer" func (m *Migrator) updateEndpointsToVersion9() error { - legacyEndpoints, err := m.EndpointService.Endpoints() + legacyEndpoints, err := m.endpointService.Endpoints() if err != nil { return err } for _, endpoint := range legacyEndpoints { endpoint.GroupID = portainer.EndpointGroupID(1) - err = m.EndpointService.UpdateEndpoint(endpoint.ID, &endpoint) + err = m.endpointService.UpdateEndpoint(endpoint.ID, &endpoint) if err != nil { return err } diff --git a/api/bolt/migrate_dbversion9.go b/api/bolt/migrator/migrate_dbversion9.go similarity index 67% rename from api/bolt/migrate_dbversion9.go rename to api/bolt/migrator/migrate_dbversion9.go index 1882f55c7..f4a52d398 100644 --- a/api/bolt/migrate_dbversion9.go +++ b/api/bolt/migrator/migrate_dbversion9.go @@ -1,16 +1,16 @@ -package bolt +package migrator import "github.com/portainer/portainer" func (m *Migrator) updateEndpointsToVersion10() error { - legacyEndpoints, err := m.EndpointService.Endpoints() + legacyEndpoints, err := m.endpointService.Endpoints() if err != nil { return err } for _, endpoint := range legacyEndpoints { endpoint.Type = portainer.DockerEnvironment - err = m.EndpointService.UpdateEndpoint(endpoint.ID, &endpoint) + err = m.endpointService.UpdateEndpoint(endpoint.ID, &endpoint) if err != nil { return err } diff --git a/api/bolt/migrator/migrator.go b/api/bolt/migrator/migrator.go new file mode 100644 index 000000000..0455aafd1 --- /dev/null +++ b/api/bolt/migrator/migrator.go @@ -0,0 +1,174 @@ +package migrator + +import ( + "github.com/boltdb/bolt" + "github.com/portainer/portainer" + "github.com/portainer/portainer/bolt/endpoint" + "github.com/portainer/portainer/bolt/endpointgroup" + "github.com/portainer/portainer/bolt/resourcecontrol" + "github.com/portainer/portainer/bolt/settings" + "github.com/portainer/portainer/bolt/stack" + "github.com/portainer/portainer/bolt/user" + "github.com/portainer/portainer/bolt/version" +) + +type ( + // Migrator defines a service to migrate data after a Portainer version update. + Migrator struct { + currentDBVersion int + db *bolt.DB + endpointGroupService *endpointgroup.Service + endpointService *endpoint.Service + resourceControlService *resourcecontrol.Service + settingsService *settings.Service + stackService *stack.Service + userService *user.Service + versionService *version.Service + fileService portainer.FileService + } + + // Parameters represents the required parameters to create a new Migrator instance. + Parameters struct { + DB *bolt.DB + DatabaseVersion int + EndpointGroupService *endpointgroup.Service + EndpointService *endpoint.Service + ResourceControlService *resourcecontrol.Service + SettingsService *settings.Service + StackService *stack.Service + UserService *user.Service + VersionService *version.Service + FileService portainer.FileService + } +) + +// NewMigrator creates a new Migrator. +func NewMigrator(parameters *Parameters) *Migrator { + return &Migrator{ + db: parameters.DB, + currentDBVersion: parameters.DatabaseVersion, + endpointGroupService: parameters.EndpointGroupService, + endpointService: parameters.EndpointService, + resourceControlService: parameters.ResourceControlService, + settingsService: parameters.SettingsService, + stackService: parameters.StackService, + userService: parameters.UserService, + versionService: parameters.VersionService, + fileService: parameters.FileService, + } +} + +// Migrate checks the database version and migrate the existing data to the most recent data model. +func (m *Migrator) Migrate() error { + + // Portainer < 1.12 + if m.currentDBVersion < 1 { + err := m.updateAdminUserToDBVersion1() + if err != nil { + return err + } + } + + // Portainer 1.12.x + if m.currentDBVersion < 2 { + err := m.updateResourceControlsToDBVersion2() + if err != nil { + return err + } + err = m.updateEndpointsToDBVersion2() + if err != nil { + return err + } + } + + // Portainer 1.13.x + if m.currentDBVersion < 3 { + err := m.updateSettingsToDBVersion3() + if err != nil { + return err + } + } + + // Portainer 1.14.0 + if m.currentDBVersion < 4 { + err := m.updateEndpointsToDBVersion4() + if err != nil { + return err + } + } + + // https://github.com/portainer/portainer/issues/1235 + if m.currentDBVersion < 5 { + err := m.updateSettingsToVersion5() + if err != nil { + return err + } + } + + // https://github.com/portainer/portainer/issues/1236 + if m.currentDBVersion < 6 { + err := m.updateSettingsToVersion6() + if err != nil { + return err + } + } + + // https://github.com/portainer/portainer/issues/1449 + if m.currentDBVersion < 7 { + err := m.updateSettingsToVersion7() + if err != nil { + return err + } + } + + if m.currentDBVersion < 8 { + err := m.updateEndpointsToVersion8() + if err != nil { + return err + } + } + + // https: //github.com/portainer/portainer/issues/1396 + if m.currentDBVersion < 9 { + err := m.updateEndpointsToVersion9() + if err != nil { + return err + } + } + + // https://github.com/portainer/portainer/issues/461 + if m.currentDBVersion < 10 { + err := m.updateEndpointsToVersion10() + if err != nil { + return err + } + } + + // https://github.com/portainer/portainer/issues/1906 + if m.currentDBVersion < 11 { + err := m.updateEndpointsToVersion11() + if err != nil { + return err + } + } + + // Portainer 1.17.1-dev + if m.currentDBVersion < 12 { + err := m.updateEndpointsToVersion12() + if err != nil { + return err + } + + err = m.updateEndpointGroupsToVersion12() + if err != nil { + return err + } + + err = m.updateStacksToVersion12() + if err != nil { + return err + } + } + + return m.versionService.StoreDBVersion(portainer.DBVersion) +} diff --git a/api/bolt/registry/registry.go b/api/bolt/registry/registry.go new file mode 100644 index 000000000..2fbfbeb90 --- /dev/null +++ b/api/bolt/registry/registry.go @@ -0,0 +1,95 @@ +package registry + +import ( + "github.com/portainer/portainer" + "github.com/portainer/portainer/bolt/internal" + + "github.com/boltdb/bolt" +) + +const ( + // BucketName represents the name of the bucket where this service stores data. + BucketName = "registries" +) + +// Service represents a service for managing endpoint data. +type Service struct { + db *bolt.DB +} + +// NewService creates a new instance of a service. +func NewService(db *bolt.DB) (*Service, error) { + err := internal.CreateBucket(db, BucketName) + if err != nil { + return nil, err + } + + return &Service{ + db: db, + }, nil +} + +// Registry returns an registry by ID. +func (service *Service) Registry(ID portainer.RegistryID) (*portainer.Registry, error) { + var registry portainer.Registry + identifier := internal.Itob(int(ID)) + + err := internal.GetObject(service.db, BucketName, identifier, ®istry) + if err != nil { + return nil, err + } + + return ®istry, nil +} + +// Registries returns an array containing all the registries. +func (service *Service) Registries() ([]portainer.Registry, error) { + var registries = make([]portainer.Registry, 0) + + err := service.db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(BucketName)) + + cursor := bucket.Cursor() + for k, v := cursor.First(); k != nil; k, v = cursor.Next() { + var registry portainer.Registry + err := internal.UnmarshalObject(v, ®istry) + if err != nil { + return err + } + registries = append(registries, registry) + } + + return nil + }) + + return registries, err +} + +// CreateRegistry creates a new registry. +func (service *Service) CreateRegistry(registry *portainer.Registry) error { + return service.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(BucketName)) + + id, _ := bucket.NextSequence() + registry.ID = portainer.RegistryID(id) + + data, err := internal.MarshalObject(registry) + if err != nil { + return err + } + + return bucket.Put(internal.Itob(int(registry.ID)), data) + }) +} + +// UpdateRegistry updates an registry. +func (service *Service) UpdateRegistry(ID portainer.RegistryID, registry *portainer.Registry) error { + identifier := internal.Itob(int(ID)) + return internal.UpdateObject(service.db, BucketName, identifier, registry) +} + +// DeleteRegistry deletes an registry. +func (service *Service) DeleteRegistry(ID portainer.RegistryID) error { + identifier := internal.Itob(int(ID)) + return internal.DeleteObject(service.db, BucketName, identifier) +} diff --git a/api/bolt/registry_service.go b/api/bolt/registry_service.go deleted file mode 100644 index a7cabb313..000000000 --- a/api/bolt/registry_service.go +++ /dev/null @@ -1,114 +0,0 @@ -package bolt - -import ( - "github.com/portainer/portainer" - "github.com/portainer/portainer/bolt/internal" - - "github.com/boltdb/bolt" -) - -// RegistryService represents a service for managing registries. -type RegistryService struct { - store *Store -} - -// Registry returns an registry by ID. -func (service *RegistryService) Registry(ID portainer.RegistryID) (*portainer.Registry, error) { - var data []byte - err := service.store.db.View(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(registryBucketName)) - value := bucket.Get(internal.Itob(int(ID))) - if value == nil { - return portainer.ErrRegistryNotFound - } - - data = make([]byte, len(value)) - copy(data, value) - return nil - }) - if err != nil { - return nil, err - } - - var registry portainer.Registry - err = internal.UnmarshalObject(data, ®istry) - if err != nil { - return nil, err - } - return ®istry, nil -} - -// Registries returns an array containing all the registries. -func (service *RegistryService) Registries() ([]portainer.Registry, error) { - var registries = make([]portainer.Registry, 0) - err := service.store.db.View(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(registryBucketName)) - - cursor := bucket.Cursor() - for k, v := cursor.First(); k != nil; k, v = cursor.Next() { - var registry portainer.Registry - err := internal.UnmarshalObject(v, ®istry) - if err != nil { - return err - } - registries = append(registries, registry) - } - - return nil - }) - if err != nil { - return nil, err - } - - return registries, nil -} - -// CreateRegistry creates a new registry. -func (service *RegistryService) CreateRegistry(registry *portainer.Registry) error { - return service.store.db.Update(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(registryBucketName)) - - id, _ := bucket.NextSequence() - registry.ID = portainer.RegistryID(id) - - data, err := internal.MarshalObject(registry) - if err != nil { - return err - } - - err = bucket.Put(internal.Itob(int(registry.ID)), data) - if err != nil { - return err - } - return nil - }) -} - -// UpdateRegistry updates an registry. -func (service *RegistryService) UpdateRegistry(ID portainer.RegistryID, registry *portainer.Registry) error { - data, err := internal.MarshalObject(registry) - if err != nil { - return err - } - - return service.store.db.Update(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(registryBucketName)) - err = bucket.Put(internal.Itob(int(ID)), data) - if err != nil { - return err - } - return nil - }) -} - -// DeleteRegistry deletes an registry. -func (service *RegistryService) DeleteRegistry(ID portainer.RegistryID) error { - return service.store.db.Update(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(registryBucketName)) - err := bucket.Delete(internal.Itob(int(ID))) - if err != nil { - return err - } - return nil - }) -} diff --git a/api/bolt/resource_control_service.go b/api/bolt/resource_control_service.go deleted file mode 100644 index cbd13fa4a..000000000 --- a/api/bolt/resource_control_service.go +++ /dev/null @@ -1,148 +0,0 @@ -package bolt - -import ( - "github.com/portainer/portainer" - "github.com/portainer/portainer/bolt/internal" - - "github.com/boltdb/bolt" -) - -// ResourceControlService represents a service for managing resource controls. -type ResourceControlService struct { - store *Store -} - -// ResourceControl returns a ResourceControl object by ID -func (service *ResourceControlService) ResourceControl(ID portainer.ResourceControlID) (*portainer.ResourceControl, error) { - var data []byte - err := service.store.db.View(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(resourceControlBucketName)) - value := bucket.Get(internal.Itob(int(ID))) - if value == nil { - return portainer.ErrResourceControlNotFound - } - - data = make([]byte, len(value)) - copy(data, value) - return nil - }) - if err != nil { - return nil, err - } - - var resourceControl portainer.ResourceControl - err = internal.UnmarshalObject(data, &resourceControl) - if err != nil { - return nil, err - } - return &resourceControl, nil -} - -// ResourceControlByResourceID returns a ResourceControl object by checking if the resourceID is equal -// to the main ResourceID or in SubResourceIDs -func (service *ResourceControlService) ResourceControlByResourceID(resourceID string) (*portainer.ResourceControl, error) { - var resourceControl *portainer.ResourceControl - - err := service.store.db.View(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(resourceControlBucketName)) - cursor := bucket.Cursor() - for k, v := cursor.First(); k != nil; k, v = cursor.Next() { - var rc portainer.ResourceControl - err := internal.UnmarshalObject(v, &rc) - if err != nil { - return err - } - if rc.ResourceID == resourceID { - resourceControl = &rc - } - for _, subResourceID := range rc.SubResourceIDs { - if subResourceID == resourceID { - resourceControl = &rc - } - } - } - - if resourceControl == nil { - return portainer.ErrResourceControlNotFound - } - return nil - }) - if err != nil { - return nil, err - } - return resourceControl, nil -} - -// ResourceControls returns all the ResourceControl objects -func (service *ResourceControlService) ResourceControls() ([]portainer.ResourceControl, error) { - var rcs = make([]portainer.ResourceControl, 0) - err := service.store.db.View(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(resourceControlBucketName)) - - cursor := bucket.Cursor() - for k, v := cursor.First(); k != nil; k, v = cursor.Next() { - var resourceControl portainer.ResourceControl - err := internal.UnmarshalObject(v, &resourceControl) - if err != nil { - return err - } - rcs = append(rcs, resourceControl) - } - - return nil - }) - if err != nil { - return nil, err - } - - return rcs, nil -} - -// CreateResourceControl creates a new ResourceControl object -func (service *ResourceControlService) CreateResourceControl(resourceControl *portainer.ResourceControl) error { - return service.store.db.Update(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(resourceControlBucketName)) - id, _ := bucket.NextSequence() - resourceControl.ID = portainer.ResourceControlID(id) - data, err := internal.MarshalObject(resourceControl) - if err != nil { - return err - } - - err = bucket.Put(internal.Itob(int(resourceControl.ID)), data) - if err != nil { - return err - } - return nil - }) -} - -// UpdateResourceControl saves a ResourceControl object. -func (service *ResourceControlService) UpdateResourceControl(ID portainer.ResourceControlID, resourceControl *portainer.ResourceControl) error { - data, err := internal.MarshalObject(resourceControl) - if err != nil { - return err - } - - return service.store.db.Update(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(resourceControlBucketName)) - err = bucket.Put(internal.Itob(int(ID)), data) - - if err != nil { - return err - } - return nil - }) -} - -// DeleteResourceControl deletes a ResourceControl object by ID -func (service *ResourceControlService) DeleteResourceControl(ID portainer.ResourceControlID) error { - return service.store.db.Update(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(resourceControlBucketName)) - err := bucket.Delete(internal.Itob(int(ID))) - if err != nil { - return err - } - return nil - }) -} diff --git a/api/bolt/resourcecontrol/resourcecontrol.go b/api/bolt/resourcecontrol/resourcecontrol.go new file mode 100644 index 000000000..222bafd79 --- /dev/null +++ b/api/bolt/resourcecontrol/resourcecontrol.go @@ -0,0 +1,134 @@ +package resourcecontrol + +import ( + "github.com/portainer/portainer" + "github.com/portainer/portainer/bolt/internal" + + "github.com/boltdb/bolt" +) + +const ( + // BucketName represents the name of the bucket where this service stores data. + BucketName = "resource_control" +) + +// Service represents a service for managing endpoint data. +type Service struct { + db *bolt.DB +} + +// NewService creates a new instance of a service. +func NewService(db *bolt.DB) (*Service, error) { + err := internal.CreateBucket(db, BucketName) + if err != nil { + return nil, err + } + + return &Service{ + db: db, + }, nil +} + +// ResourceControl returns a ResourceControl object by ID +func (service *Service) ResourceControl(ID portainer.ResourceControlID) (*portainer.ResourceControl, error) { + var resourceControl portainer.ResourceControl + identifier := internal.Itob(int(ID)) + + err := internal.GetObject(service.db, BucketName, identifier, &resourceControl) + if err != nil { + return nil, err + } + + return &resourceControl, nil +} + +// ResourceControlByResourceID returns a ResourceControl object by checking if the resourceID is equal +// to the main ResourceID or in SubResourceIDs +func (service *Service) ResourceControlByResourceID(resourceID string) (*portainer.ResourceControl, error) { + var resourceControl *portainer.ResourceControl + + err := service.db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(BucketName)) + cursor := bucket.Cursor() + + for k, v := cursor.First(); k != nil; k, v = cursor.Next() { + var rc portainer.ResourceControl + err := internal.UnmarshalObject(v, &rc) + if err != nil { + return err + } + + if rc.ResourceID == resourceID { + resourceControl = &rc + break + } + + for _, subResourceID := range rc.SubResourceIDs { + if subResourceID == resourceID { + resourceControl = &rc + break + } + } + } + + if resourceControl == nil { + return portainer.ErrObjectNotFound + } + + return nil + }) + + return resourceControl, err +} + +// ResourceControls returns all the ResourceControl objects +func (service *Service) ResourceControls() ([]portainer.ResourceControl, error) { + var rcs = make([]portainer.ResourceControl, 0) + + err := service.db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(BucketName)) + + cursor := bucket.Cursor() + for k, v := cursor.First(); k != nil; k, v = cursor.Next() { + var resourceControl portainer.ResourceControl + err := internal.UnmarshalObject(v, &resourceControl) + if err != nil { + return err + } + rcs = append(rcs, resourceControl) + } + + return nil + }) + + return rcs, err +} + +// CreateResourceControl creates a new ResourceControl object +func (service *Service) CreateResourceControl(resourceControl *portainer.ResourceControl) error { + return service.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(BucketName)) + + id, _ := bucket.NextSequence() + resourceControl.ID = portainer.ResourceControlID(id) + + data, err := internal.MarshalObject(resourceControl) + if err != nil { + return err + } + + return bucket.Put(internal.Itob(int(resourceControl.ID)), data) + }) +} + +// UpdateResourceControl saves a ResourceControl object. +func (service *Service) UpdateResourceControl(ID portainer.ResourceControlID, resourceControl *portainer.ResourceControl) error { + identifier := internal.Itob(int(ID)) + return internal.UpdateObject(service.db, BucketName, identifier, resourceControl) +} + +// DeleteResourceControl deletes a ResourceControl object by ID +func (service *Service) DeleteResourceControl(ID portainer.ResourceControlID) error { + identifier := internal.Itob(int(ID)) + return internal.DeleteObject(service.db, BucketName, identifier) +} diff --git a/api/bolt/settings/settings.go b/api/bolt/settings/settings.go new file mode 100644 index 000000000..6e1d4bc82 --- /dev/null +++ b/api/bolt/settings/settings.go @@ -0,0 +1,48 @@ +package settings + +import ( + "github.com/portainer/portainer" + "github.com/portainer/portainer/bolt/internal" + + "github.com/boltdb/bolt" +) + +const ( + // BucketName represents the name of the bucket where this service stores data. + BucketName = "settings" + settingsKey = "SETTINGS" +) + +// Service represents a service for managing endpoint data. +type Service struct { + db *bolt.DB +} + +// NewService creates a new instance of a service. +func NewService(db *bolt.DB) (*Service, error) { + err := internal.CreateBucket(db, BucketName) + if err != nil { + return nil, err + } + + return &Service{ + db: db, + }, nil +} + +// Settings retrieve the settings object. +func (service *Service) Settings() (*portainer.Settings, error) { + var settings portainer.Settings + + err := internal.GetObject(service.db, BucketName, []byte(settingsKey), &settings) + if err != nil { + return nil, err + } + + return &settings, nil +} + +// UpdateSettings persists a Settings object. +func (service *Service) UpdateSettings(settings *portainer.Settings) error { + return internal.UpdateObject(service.db, BucketName, []byte(settingsKey), settings) +} diff --git a/api/bolt/settings_service.go b/api/bolt/settings_service.go deleted file mode 100644 index 2053feac9..000000000 --- a/api/bolt/settings_service.go +++ /dev/null @@ -1,61 +0,0 @@ -package bolt - -import ( - "github.com/portainer/portainer" - "github.com/portainer/portainer/bolt/internal" - - "github.com/boltdb/bolt" -) - -// SettingsService represents a service to manage application settings. -type SettingsService struct { - store *Store -} - -const ( - dbSettingsKey = "SETTINGS" -) - -// Settings retrieve the settings object. -func (service *SettingsService) Settings() (*portainer.Settings, error) { - var data []byte - err := service.store.db.View(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(settingsBucketName)) - value := bucket.Get([]byte(dbSettingsKey)) - if value == nil { - return portainer.ErrSettingsNotFound - } - - data = make([]byte, len(value)) - copy(data, value) - return nil - }) - if err != nil { - return nil, err - } - - var settings portainer.Settings - err = internal.UnmarshalObject(data, &settings) - if err != nil { - return nil, err - } - return &settings, nil -} - -// StoreSettings persists a Settings object. -func (service *SettingsService) StoreSettings(settings *portainer.Settings) error { - return service.store.db.Update(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(settingsBucketName)) - - data, err := internal.MarshalObject(settings) - if err != nil { - return err - } - - err = bucket.Put([]byte(dbSettingsKey), data) - if err != nil { - return err - } - return nil - }) -} diff --git a/api/bolt/stack/stack.go b/api/bolt/stack/stack.go new file mode 100644 index 000000000..1bd8e159b --- /dev/null +++ b/api/bolt/stack/stack.go @@ -0,0 +1,134 @@ +package stack + +import ( + "github.com/portainer/portainer" + "github.com/portainer/portainer/bolt/internal" + + "github.com/boltdb/bolt" +) + +const ( + // BucketName represents the name of the bucket where this service stores data. + BucketName = "stacks" +) + +// Service represents a service for managing endpoint data. +type Service struct { + db *bolt.DB +} + +// NewService creates a new instance of a service. +func NewService(db *bolt.DB) (*Service, error) { + err := internal.CreateBucket(db, BucketName) + if err != nil { + return nil, err + } + + return &Service{ + db: db, + }, nil +} + +// Stack returns a stack object by ID. +func (service *Service) Stack(ID portainer.StackID) (*portainer.Stack, error) { + var stack portainer.Stack + identifier := internal.Itob(int(ID)) + + err := internal.GetObject(service.db, BucketName, identifier, &stack) + if err != nil { + return nil, err + } + + return &stack, nil +} + +// StackByName returns a stack object by name. +func (service *Service) StackByName(name string) (*portainer.Stack, error) { + var stack *portainer.Stack + + err := service.db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(BucketName)) + cursor := bucket.Cursor() + + for k, v := cursor.First(); k != nil; k, v = cursor.Next() { + var t portainer.Stack + err := internal.UnmarshalObject(v, &t) + if err != nil { + return err + } + + if t.Name == name { + stack = &t + break + } + } + + if stack == nil { + return portainer.ErrObjectNotFound + } + + return nil + }) + + return stack, err +} + +// Stacks returns an array containing all the stacks. +func (service *Service) Stacks() ([]portainer.Stack, error) { + var stacks = make([]portainer.Stack, 0) + + err := service.db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(BucketName)) + + cursor := bucket.Cursor() + for k, v := cursor.First(); k != nil; k, v = cursor.Next() { + var stack portainer.Stack + err := internal.UnmarshalObject(v, &stack) + if err != nil { + return err + } + stacks = append(stacks, stack) + } + + return nil + }) + + return stacks, err +} + +// GetNextIdentifier returns the next identifier for a stack. +func (service *Service) GetNextIdentifier() int { + return internal.GetNextIdentifier(service.db, BucketName) +} + +// CreateStack creates a new stack. +func (service *Service) CreateStack(stack *portainer.Stack) error { + return service.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(BucketName)) + + // We manually manage sequences for stacks + err := bucket.SetSequence(uint64(stack.ID)) + if err != nil { + return err + } + + data, err := internal.MarshalObject(stack) + if err != nil { + return err + } + + return bucket.Put(internal.Itob(int(stack.ID)), data) + }) +} + +// UpdateStack updates a stack. +func (service *Service) UpdateStack(ID portainer.StackID, stack *portainer.Stack) error { + identifier := internal.Itob(int(ID)) + return internal.UpdateObject(service.db, BucketName, identifier, stack) +} + +// DeleteStack deletes a stack. +func (service *Service) DeleteStack(ID portainer.StackID) error { + identifier := internal.Itob(int(ID)) + return internal.DeleteObject(service.db, BucketName, identifier) +} diff --git a/api/bolt/stack_service.go b/api/bolt/stack_service.go deleted file mode 100644 index 34855e59f..000000000 --- a/api/bolt/stack_service.go +++ /dev/null @@ -1,159 +0,0 @@ -package bolt - -import ( - "github.com/portainer/portainer" - "github.com/portainer/portainer/bolt/internal" - - "github.com/boltdb/bolt" -) - -// StackService represents a service for managing stacks. -type StackService struct { - store *Store -} - -// Stack returns a stack object by ID. -func (service *StackService) Stack(ID portainer.StackID) (*portainer.Stack, error) { - var data []byte - err := service.store.db.View(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(stackBucketName)) - value := bucket.Get(internal.Itob(int(ID))) - if value == nil { - return portainer.ErrStackNotFound - } - - data = make([]byte, len(value)) - copy(data, value) - return nil - }) - if err != nil { - return nil, err - } - - var stack portainer.Stack - err = internal.UnmarshalObject(data, &stack) - if err != nil { - return nil, err - } - return &stack, nil -} - -// StackByName returns a stack object by name. -func (service *StackService) StackByName(name string) (*portainer.Stack, error) { - var stack *portainer.Stack - - err := service.store.db.View(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(stackBucketName)) - cursor := bucket.Cursor() - for k, v := cursor.First(); k != nil; k, v = cursor.Next() { - var t portainer.Stack - err := internal.UnmarshalObject(v, &t) - if err != nil { - return err - } - if t.Name == name { - stack = &t - } - } - - if stack == nil { - return portainer.ErrStackNotFound - } - return nil - }) - if err != nil { - return nil, err - } - return stack, nil -} - -// Stacks returns an array containing all the stacks. -func (service *StackService) Stacks() ([]portainer.Stack, error) { - var stacks = make([]portainer.Stack, 0) - err := service.store.db.View(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(stackBucketName)) - - cursor := bucket.Cursor() - for k, v := cursor.First(); k != nil; k, v = cursor.Next() { - var stack portainer.Stack - err := internal.UnmarshalObject(v, &stack) - if err != nil { - return err - } - stacks = append(stacks, stack) - } - - return nil - }) - if err != nil { - return nil, err - } - - return stacks, nil -} - -// GetNextIdentifier returns the current bucket identifier incremented by 1. -func (service *StackService) GetNextIdentifier() int { - var identifier int - - service.store.db.View(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(stackBucketName)) - id := bucket.Sequence() - identifier = int(id) - return nil - }) - - identifier++ - return identifier -} - -// CreateStack creates a new stack. -func (service *StackService) CreateStack(stack *portainer.Stack) error { - return service.store.db.Update(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(stackBucketName)) - err := bucket.SetSequence(uint64(stack.ID)) - if err != nil { - return err - } - - data, err := internal.MarshalObject(stack) - if err != nil { - return err - } - - err = bucket.Put(internal.Itob(int(stack.ID)), data) - if err != nil { - return err - } - return nil - }) -} - -// UpdateStack updates an stack. -func (service *StackService) UpdateStack(ID portainer.StackID, stack *portainer.Stack) error { - data, err := internal.MarshalObject(stack) - if err != nil { - return err - } - - return service.store.db.Update(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(stackBucketName)) - err = bucket.Put(internal.Itob(int(ID)), data) - if err != nil { - return err - } - return nil - }) -} - -// DeleteStack deletes an stack. -func (service *StackService) DeleteStack(ID portainer.StackID) error { - return service.store.db.Update(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(stackBucketName)) - err := bucket.Delete(internal.Itob(int(ID))) - if err != nil { - return err - } - return nil - }) -} diff --git a/api/bolt/tag/tag.go b/api/bolt/tag/tag.go new file mode 100644 index 000000000..796b463dd --- /dev/null +++ b/api/bolt/tag/tag.go @@ -0,0 +1,76 @@ +package tag + +import ( + "github.com/portainer/portainer" + "github.com/portainer/portainer/bolt/internal" + + "github.com/boltdb/bolt" +) + +const ( + // BucketName represents the name of the bucket where this service stores data. + BucketName = "tags" +) + +// Service represents a service for managing endpoint data. +type Service struct { + db *bolt.DB +} + +// NewService creates a new instance of a service. +func NewService(db *bolt.DB) (*Service, error) { + err := internal.CreateBucket(db, BucketName) + if err != nil { + return nil, err + } + + return &Service{ + db: db, + }, nil +} + +// Tags return an array containing all the tags. +func (service *Service) Tags() ([]portainer.Tag, error) { + var tags = make([]portainer.Tag, 0) + + err := service.db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(BucketName)) + + cursor := bucket.Cursor() + for k, v := cursor.First(); k != nil; k, v = cursor.Next() { + var tag portainer.Tag + err := internal.UnmarshalObject(v, &tag) + if err != nil { + return err + } + tags = append(tags, tag) + } + + return nil + }) + + return tags, err +} + +// CreateTag creates a new tag. +func (service *Service) CreateTag(tag *portainer.Tag) error { + return service.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(BucketName)) + + id, _ := bucket.NextSequence() + tag.ID = portainer.TagID(id) + + data, err := internal.MarshalObject(tag) + if err != nil { + return err + } + + return bucket.Put(internal.Itob(int(tag.ID)), data) + }) +} + +// DeleteTag deletes a tag. +func (service *Service) DeleteTag(ID portainer.TagID) error { + identifier := internal.Itob(int(ID)) + return internal.DeleteObject(service.db, BucketName, identifier) +} diff --git a/api/bolt/tag_service.go b/api/bolt/tag_service.go deleted file mode 100644 index 9ff0fb244..000000000 --- a/api/bolt/tag_service.go +++ /dev/null @@ -1,71 +0,0 @@ -package bolt - -import ( - "github.com/portainer/portainer" - "github.com/portainer/portainer/bolt/internal" - - "github.com/boltdb/bolt" -) - -// TagService represents a service for managing tags. -type TagService struct { - store *Store -} - -// Tags return an array containing all the tags. -func (service *TagService) Tags() ([]portainer.Tag, error) { - var tags = make([]portainer.Tag, 0) - err := service.store.db.View(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(tagBucketName)) - - cursor := bucket.Cursor() - for k, v := cursor.First(); k != nil; k, v = cursor.Next() { - var tag portainer.Tag - err := internal.UnmarshalObject(v, &tag) - if err != nil { - return err - } - tags = append(tags, tag) - } - - return nil - }) - if err != nil { - return nil, err - } - - return tags, nil -} - -// CreateTag creates a new tag. -func (service *TagService) CreateTag(tag *portainer.Tag) error { - return service.store.db.Update(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(tagBucketName)) - - id, _ := bucket.NextSequence() - tag.ID = portainer.TagID(id) - - data, err := internal.MarshalObject(tag) - if err != nil { - return err - } - - err = bucket.Put(internal.Itob(int(tag.ID)), data) - if err != nil { - return err - } - return nil - }) -} - -// DeleteTag deletes a tag. -func (service *TagService) DeleteTag(ID portainer.TagID) error { - return service.store.db.Update(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(tagBucketName)) - err := bucket.Delete(internal.Itob(int(ID))) - if err != nil { - return err - } - return nil - }) -} diff --git a/api/bolt/team/team.go b/api/bolt/team/team.go new file mode 100644 index 000000000..189a16a8a --- /dev/null +++ b/api/bolt/team/team.go @@ -0,0 +1,126 @@ +package team + +import ( + "github.com/portainer/portainer" + "github.com/portainer/portainer/bolt/internal" + + "github.com/boltdb/bolt" +) + +const ( + // BucketName represents the name of the bucket where this service stores data. + BucketName = "teams" +) + +// Service represents a service for managing endpoint data. +type Service struct { + db *bolt.DB +} + +// NewService creates a new instance of a service. +func NewService(db *bolt.DB) (*Service, error) { + err := internal.CreateBucket(db, BucketName) + if err != nil { + return nil, err + } + + return &Service{ + db: db, + }, nil +} + +// Team returns a Team by ID +func (service *Service) Team(ID portainer.TeamID) (*portainer.Team, error) { + var team portainer.Team + identifier := internal.Itob(int(ID)) + + err := internal.GetObject(service.db, BucketName, identifier, &team) + if err != nil { + return nil, err + } + + return &team, nil +} + +// TeamByName returns a team by name. +func (service *Service) TeamByName(name string) (*portainer.Team, error) { + var team *portainer.Team + + err := service.db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(BucketName)) + + cursor := bucket.Cursor() + for k, v := cursor.First(); k != nil; k, v = cursor.Next() { + var t portainer.Team + err := internal.UnmarshalObject(v, &t) + if err != nil { + return err + } + + if t.Name == name { + team = &t + break + } + } + + if team == nil { + return portainer.ErrObjectNotFound + } + + return nil + }) + + return team, err +} + +// Teams return an array containing all the teams. +func (service *Service) Teams() ([]portainer.Team, error) { + var teams = make([]portainer.Team, 0) + + err := service.db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(BucketName)) + + cursor := bucket.Cursor() + for k, v := cursor.First(); k != nil; k, v = cursor.Next() { + var team portainer.Team + err := internal.UnmarshalObject(v, &team) + if err != nil { + return err + } + teams = append(teams, team) + } + + return nil + }) + + return teams, err +} + +// UpdateTeam saves a Team. +func (service *Service) UpdateTeam(ID portainer.TeamID, team *portainer.Team) error { + identifier := internal.Itob(int(ID)) + return internal.UpdateObject(service.db, BucketName, identifier, team) +} + +// CreateTeam creates a new Team. +func (service *Service) CreateTeam(team *portainer.Team) error { + return service.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(BucketName)) + + id, _ := bucket.NextSequence() + team.ID = portainer.TeamID(id) + + data, err := internal.MarshalObject(team) + if err != nil { + return err + } + + return bucket.Put(internal.Itob(int(team.ID)), data) + }) +} + +// DeleteTeam deletes a Team. +func (service *Service) DeleteTeam(ID portainer.TeamID) error { + identifier := internal.Itob(int(ID)) + return internal.DeleteObject(service.db, BucketName, identifier) +} diff --git a/api/bolt/team_service.go b/api/bolt/team_service.go deleted file mode 100644 index 61ba23207..000000000 --- a/api/bolt/team_service.go +++ /dev/null @@ -1,144 +0,0 @@ -package bolt - -import ( - "github.com/portainer/portainer" - "github.com/portainer/portainer/bolt/internal" - - "github.com/boltdb/bolt" -) - -// TeamService represents a service for managing teams. -type TeamService struct { - store *Store -} - -// Team returns a Team by ID -func (service *TeamService) Team(ID portainer.TeamID) (*portainer.Team, error) { - var data []byte - err := service.store.db.View(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(teamBucketName)) - value := bucket.Get(internal.Itob(int(ID))) - if value == nil { - return portainer.ErrTeamNotFound - } - - data = make([]byte, len(value)) - copy(data, value) - return nil - }) - if err != nil { - return nil, err - } - - var team portainer.Team - err = internal.UnmarshalObject(data, &team) - if err != nil { - return nil, err - } - return &team, nil -} - -// TeamByName returns a team by name. -func (service *TeamService) TeamByName(name string) (*portainer.Team, error) { - var team *portainer.Team - - err := service.store.db.View(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(teamBucketName)) - cursor := bucket.Cursor() - for k, v := cursor.First(); k != nil; k, v = cursor.Next() { - var t portainer.Team - err := internal.UnmarshalObject(v, &t) - if err != nil { - return err - } - if t.Name == name { - team = &t - } - } - - if team == nil { - return portainer.ErrTeamNotFound - } - return nil - }) - if err != nil { - return nil, err - } - return team, nil -} - -// Teams return an array containing all the teams. -func (service *TeamService) Teams() ([]portainer.Team, error) { - var teams = make([]portainer.Team, 0) - err := service.store.db.View(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(teamBucketName)) - - cursor := bucket.Cursor() - for k, v := cursor.First(); k != nil; k, v = cursor.Next() { - var team portainer.Team - err := internal.UnmarshalObject(v, &team) - if err != nil { - return err - } - teams = append(teams, team) - } - - return nil - }) - if err != nil { - return nil, err - } - - return teams, nil -} - -// UpdateTeam saves a Team. -func (service *TeamService) UpdateTeam(ID portainer.TeamID, team *portainer.Team) error { - data, err := internal.MarshalObject(team) - if err != nil { - return err - } - - return service.store.db.Update(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(teamBucketName)) - err = bucket.Put(internal.Itob(int(ID)), data) - - if err != nil { - return err - } - return nil - }) -} - -// CreateTeam creates a new Team. -func (service *TeamService) CreateTeam(team *portainer.Team) error { - return service.store.db.Update(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(teamBucketName)) - - id, _ := bucket.NextSequence() - team.ID = portainer.TeamID(id) - - data, err := internal.MarshalObject(team) - if err != nil { - return err - } - - err = bucket.Put(internal.Itob(int(team.ID)), data) - if err != nil { - return err - } - return nil - }) -} - -// DeleteTeam deletes a Team. -func (service *TeamService) DeleteTeam(ID portainer.TeamID) error { - return service.store.db.Update(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(teamBucketName)) - err := bucket.Delete(internal.Itob(int(ID))) - if err != nil { - return err - } - return nil - }) -} diff --git a/api/bolt/team_membership_service.go b/api/bolt/teammembership/teammembership.go similarity index 51% rename from api/bolt/team_membership_service.go rename to api/bolt/teammembership/teammembership.go index b56a4bed7..2f09e0ad7 100644 --- a/api/bolt/team_membership_service.go +++ b/api/bolt/teammembership/teammembership.go @@ -1,4 +1,4 @@ -package bolt +package teammembership import ( "github.com/portainer/portainer" @@ -7,42 +7,47 @@ import ( "github.com/boltdb/bolt" ) -// TeamMembershipService represents a service for managing TeamMembership objects. -type TeamMembershipService struct { - store *Store +const ( + // BucketName represents the name of the bucket where this service stores data. + BucketName = "team_membership" +) + +// Service represents a service for managing endpoint data. +type Service struct { + db *bolt.DB +} + +// NewService creates a new instance of a service. +func NewService(db *bolt.DB) (*Service, error) { + err := internal.CreateBucket(db, BucketName) + if err != nil { + return nil, err + } + + return &Service{ + db: db, + }, nil } // TeamMembership returns a TeamMembership object by ID -func (service *TeamMembershipService) TeamMembership(ID portainer.TeamMembershipID) (*portainer.TeamMembership, error) { - var data []byte - err := service.store.db.View(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(teamMembershipBucketName)) - value := bucket.Get(internal.Itob(int(ID))) - if value == nil { - return portainer.ErrTeamMembershipNotFound - } - - data = make([]byte, len(value)) - copy(data, value) - return nil - }) - if err != nil { - return nil, err - } - +func (service *Service) TeamMembership(ID portainer.TeamMembershipID) (*portainer.TeamMembership, error) { var membership portainer.TeamMembership - err = internal.UnmarshalObject(data, &membership) + identifier := internal.Itob(int(ID)) + + err := internal.GetObject(service.db, BucketName, identifier, &membership) if err != nil { return nil, err } + return &membership, nil } // TeamMemberships return an array containing all the TeamMembership objects. -func (service *TeamMembershipService) TeamMemberships() ([]portainer.TeamMembership, error) { +func (service *Service) TeamMemberships() ([]portainer.TeamMembership, error) { var memberships = make([]portainer.TeamMembership, 0) - err := service.store.db.View(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(teamMembershipBucketName)) + + err := service.db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(BucketName)) cursor := bucket.Cursor() for k, v := cursor.First(); k != nil; k, v = cursor.Next() { @@ -56,18 +61,16 @@ func (service *TeamMembershipService) TeamMemberships() ([]portainer.TeamMembers return nil }) - if err != nil { - return nil, err - } - return memberships, nil + return memberships, err } // TeamMembershipsByUserID return an array containing all the TeamMembership objects where the specified userID is present. -func (service *TeamMembershipService) TeamMembershipsByUserID(userID portainer.UserID) ([]portainer.TeamMembership, error) { +func (service *Service) TeamMembershipsByUserID(userID portainer.UserID) ([]portainer.TeamMembership, error) { var memberships = make([]portainer.TeamMembership, 0) - err := service.store.db.View(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(teamMembershipBucketName)) + + err := service.db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(BucketName)) cursor := bucket.Cursor() for k, v := cursor.First(); k != nil; k, v = cursor.Next() { @@ -76,6 +79,7 @@ func (service *TeamMembershipService) TeamMembershipsByUserID(userID portainer.U if err != nil { return err } + if membership.UserID == userID { memberships = append(memberships, membership) } @@ -83,18 +87,16 @@ func (service *TeamMembershipService) TeamMembershipsByUserID(userID portainer.U return nil }) - if err != nil { - return nil, err - } - return memberships, nil + return memberships, err } // TeamMembershipsByTeamID return an array containing all the TeamMembership objects where the specified teamID is present. -func (service *TeamMembershipService) TeamMembershipsByTeamID(teamID portainer.TeamID) ([]portainer.TeamMembership, error) { +func (service *Service) TeamMembershipsByTeamID(teamID portainer.TeamID) ([]portainer.TeamMembership, error) { var memberships = make([]portainer.TeamMembership, 0) - err := service.store.db.View(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(teamMembershipBucketName)) + + err := service.db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(BucketName)) cursor := bucket.Cursor() for k, v := cursor.First(); k != nil; k, v = cursor.Next() { @@ -103,6 +105,7 @@ func (service *TeamMembershipService) TeamMembershipsByTeamID(teamID portainer.T if err != nil { return err } + if membership.TeamID == teamID { memberships = append(memberships, membership) } @@ -110,35 +113,20 @@ func (service *TeamMembershipService) TeamMembershipsByTeamID(teamID portainer.T return nil }) - if err != nil { - return nil, err - } - return memberships, nil + return memberships, err } // UpdateTeamMembership saves a TeamMembership object. -func (service *TeamMembershipService) UpdateTeamMembership(ID portainer.TeamMembershipID, membership *portainer.TeamMembership) error { - data, err := internal.MarshalObject(membership) - if err != nil { - return err - } - - return service.store.db.Update(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(teamMembershipBucketName)) - err = bucket.Put(internal.Itob(int(ID)), data) - - if err != nil { - return err - } - return nil - }) +func (service *Service) UpdateTeamMembership(ID portainer.TeamMembershipID, membership *portainer.TeamMembership) error { + identifier := internal.Itob(int(ID)) + return internal.UpdateObject(service.db, BucketName, identifier, membership) } // CreateTeamMembership creates a new TeamMembership object. -func (service *TeamMembershipService) CreateTeamMembership(membership *portainer.TeamMembership) error { - return service.store.db.Update(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(teamMembershipBucketName)) +func (service *Service) CreateTeamMembership(membership *portainer.TeamMembership) error { + return service.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(BucketName)) id, _ := bucket.NextSequence() membership.ID = portainer.TeamMembershipID(id) @@ -148,30 +136,20 @@ func (service *TeamMembershipService) CreateTeamMembership(membership *portainer return err } - err = bucket.Put(internal.Itob(int(membership.ID)), data) - if err != nil { - return err - } - return nil + return bucket.Put(internal.Itob(int(membership.ID)), data) }) } // DeleteTeamMembership deletes a TeamMembership object. -func (service *TeamMembershipService) DeleteTeamMembership(ID portainer.TeamMembershipID) error { - return service.store.db.Update(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(teamMembershipBucketName)) - err := bucket.Delete(internal.Itob(int(ID))) - if err != nil { - return err - } - return nil - }) +func (service *Service) DeleteTeamMembership(ID portainer.TeamMembershipID) error { + identifier := internal.Itob(int(ID)) + return internal.DeleteObject(service.db, BucketName, identifier) } // DeleteTeamMembershipByUserID deletes all the TeamMembership object associated to a UserID. -func (service *TeamMembershipService) DeleteTeamMembershipByUserID(userID portainer.UserID) error { - return service.store.db.Update(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(teamMembershipBucketName)) +func (service *Service) DeleteTeamMembershipByUserID(userID portainer.UserID) error { + return service.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(BucketName)) cursor := bucket.Cursor() for k, v := cursor.First(); k != nil; k, v = cursor.Next() { @@ -180,6 +158,7 @@ func (service *TeamMembershipService) DeleteTeamMembershipByUserID(userID portai if err != nil { return err } + if membership.UserID == userID { err := bucket.Delete(internal.Itob(int(membership.ID))) if err != nil { @@ -193,9 +172,9 @@ func (service *TeamMembershipService) DeleteTeamMembershipByUserID(userID portai } // DeleteTeamMembershipByTeamID deletes all the TeamMembership object associated to a TeamID. -func (service *TeamMembershipService) DeleteTeamMembershipByTeamID(teamID portainer.TeamID) error { - return service.store.db.Update(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(teamMembershipBucketName)) +func (service *Service) DeleteTeamMembershipByTeamID(teamID portainer.TeamID) error { + return service.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(BucketName)) cursor := bucket.Cursor() for k, v := cursor.First(); k != nil; k, v = cursor.Next() { @@ -204,6 +183,7 @@ func (service *TeamMembershipService) DeleteTeamMembershipByTeamID(teamID portai if err != nil { return err } + if membership.TeamID == teamID { err := bucket.Delete(internal.Itob(int(membership.ID))) if err != nil { diff --git a/api/bolt/user/user.go b/api/bolt/user/user.go new file mode 100644 index 000000000..03a3a8d88 --- /dev/null +++ b/api/bolt/user/user.go @@ -0,0 +1,149 @@ +package user + +import ( + "github.com/portainer/portainer" + "github.com/portainer/portainer/bolt/internal" + + "github.com/boltdb/bolt" +) + +const ( + // BucketName represents the name of the bucket where this service stores data. + BucketName = "users" +) + +// Service represents a service for managing endpoint data. +type Service struct { + db *bolt.DB +} + +// NewService creates a new instance of a service. +func NewService(db *bolt.DB) (*Service, error) { + err := internal.CreateBucket(db, BucketName) + if err != nil { + return nil, err + } + + return &Service{ + db: db, + }, nil +} + +// User returns a user by ID +func (service *Service) User(ID portainer.UserID) (*portainer.User, error) { + var user portainer.User + identifier := internal.Itob(int(ID)) + + err := internal.GetObject(service.db, BucketName, identifier, &user) + if err != nil { + return nil, err + } + + return &user, nil +} + +// UserByUsername returns a user by username. +func (service *Service) UserByUsername(username string) (*portainer.User, error) { + var user *portainer.User + + err := service.db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(BucketName)) + cursor := bucket.Cursor() + + for k, v := cursor.First(); k != nil; k, v = cursor.Next() { + var u portainer.User + err := internal.UnmarshalObject(v, &u) + if err != nil { + return err + } + + if u.Username == username { + user = &u + break + } + } + + if user == nil { + return portainer.ErrObjectNotFound + } + return nil + }) + + return user, err +} + +// Users return an array containing all the users. +func (service *Service) Users() ([]portainer.User, error) { + var users = make([]portainer.User, 0) + + err := service.db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(BucketName)) + + cursor := bucket.Cursor() + for k, v := cursor.First(); k != nil; k, v = cursor.Next() { + var user portainer.User + err := internal.UnmarshalObject(v, &user) + if err != nil { + return err + } + users = append(users, user) + } + + return nil + }) + + return users, err +} + +// UsersByRole return an array containing all the users with the specified role. +func (service *Service) UsersByRole(role portainer.UserRole) ([]portainer.User, error) { + var users = make([]portainer.User, 0) + err := service.db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(BucketName)) + + cursor := bucket.Cursor() + for k, v := cursor.First(); k != nil; k, v = cursor.Next() { + var user portainer.User + err := internal.UnmarshalObject(v, &user) + if err != nil { + return err + } + + if user.Role == role { + users = append(users, user) + } + } + return nil + }) + + return users, err +} + +// UpdateUser saves a user. +func (service *Service) UpdateUser(ID portainer.UserID, user *portainer.User) error { + identifier := internal.Itob(int(ID)) + return internal.UpdateObject(service.db, BucketName, identifier, user) +} + +// CreateUser creates a new user. +func (service *Service) CreateUser(user *portainer.User) error { + return service.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(BucketName)) + + id, _ := bucket.NextSequence() + user.ID = portainer.UserID(id) + + data, err := internal.MarshalObject(user) + if err != nil { + return err + } + + return bucket.Put(internal.Itob(int(user.ID)), data) + }) +} + +// DeleteUser deletes a user. +func (service *Service) DeleteUser(ID portainer.UserID) error { + identifier := internal.Itob(int(ID)) + return internal.DeleteObject(service.db, BucketName, identifier) +} diff --git a/api/bolt/user_service.go b/api/bolt/user_service.go deleted file mode 100644 index 55723f321..000000000 --- a/api/bolt/user_service.go +++ /dev/null @@ -1,170 +0,0 @@ -package bolt - -import ( - "github.com/portainer/portainer" - "github.com/portainer/portainer/bolt/internal" - - "github.com/boltdb/bolt" -) - -// UserService represents a service for managing users. -type UserService struct { - store *Store -} - -// User returns a user by ID -func (service *UserService) User(ID portainer.UserID) (*portainer.User, error) { - var data []byte - err := service.store.db.View(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(userBucketName)) - value := bucket.Get(internal.Itob(int(ID))) - if value == nil { - return portainer.ErrUserNotFound - } - - data = make([]byte, len(value)) - copy(data, value) - return nil - }) - if err != nil { - return nil, err - } - - var user portainer.User - err = internal.UnmarshalObject(data, &user) - if err != nil { - return nil, err - } - return &user, nil -} - -// UserByUsername returns a user by username. -func (service *UserService) UserByUsername(username string) (*portainer.User, error) { - var user *portainer.User - - err := service.store.db.View(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(userBucketName)) - cursor := bucket.Cursor() - for k, v := cursor.First(); k != nil; k, v = cursor.Next() { - var u portainer.User - err := internal.UnmarshalObject(v, &u) - if err != nil { - return err - } - if u.Username == username { - user = &u - } - } - - if user == nil { - return portainer.ErrUserNotFound - } - return nil - }) - if err != nil { - return nil, err - } - return user, nil -} - -// Users return an array containing all the users. -func (service *UserService) Users() ([]portainer.User, error) { - var users = make([]portainer.User, 0) - err := service.store.db.View(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(userBucketName)) - - cursor := bucket.Cursor() - for k, v := cursor.First(); k != nil; k, v = cursor.Next() { - var user portainer.User - err := internal.UnmarshalObject(v, &user) - if err != nil { - return err - } - users = append(users, user) - } - - return nil - }) - if err != nil { - return nil, err - } - - return users, nil -} - -// UsersByRole return an array containing all the users with the specified role. -func (service *UserService) UsersByRole(role portainer.UserRole) ([]portainer.User, error) { - var users = make([]portainer.User, 0) - err := service.store.db.View(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(userBucketName)) - - cursor := bucket.Cursor() - for k, v := cursor.First(); k != nil; k, v = cursor.Next() { - var user portainer.User - err := internal.UnmarshalObject(v, &user) - if err != nil { - return err - } - if user.Role == role { - users = append(users, user) - } - } - return nil - }) - if err != nil { - return nil, err - } - - return users, nil -} - -// UpdateUser saves a user. -func (service *UserService) UpdateUser(ID portainer.UserID, user *portainer.User) error { - data, err := internal.MarshalObject(user) - if err != nil { - return err - } - - return service.store.db.Update(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(userBucketName)) - err = bucket.Put(internal.Itob(int(ID)), data) - - if err != nil { - return err - } - return nil - }) -} - -// CreateUser creates a new user. -func (service *UserService) CreateUser(user *portainer.User) error { - return service.store.db.Update(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(userBucketName)) - - id, _ := bucket.NextSequence() - user.ID = portainer.UserID(id) - - data, err := internal.MarshalObject(user) - if err != nil { - return err - } - - err = bucket.Put(internal.Itob(int(user.ID)), data) - if err != nil { - return err - } - return nil - }) -} - -// DeleteUser deletes a user. -func (service *UserService) DeleteUser(ID portainer.UserID) error { - return service.store.db.Update(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(userBucketName)) - err := bucket.Delete(internal.Itob(int(ID))) - if err != nil { - return err - } - return nil - }) -} diff --git a/api/bolt/version/version.go b/api/bolt/version/version.go new file mode 100644 index 000000000..52a7b4be4 --- /dev/null +++ b/api/bolt/version/version.go @@ -0,0 +1,66 @@ +package version + +import ( + "strconv" + + "github.com/boltdb/bolt" + "github.com/portainer/portainer" + "github.com/portainer/portainer/bolt/internal" +) + +const ( + // BucketName represents the name of the bucket where this service stores data. + BucketName = "version" + versionKey = "DB_VERSION" +) + +// Service represents a service to manage stored versions. +type Service struct { + db *bolt.DB +} + +// NewService creates a new instance of a service. +func NewService(db *bolt.DB) (*Service, error) { + err := internal.CreateBucket(db, BucketName) + if err != nil { + return nil, err + } + + return &Service{ + db: db, + }, nil +} + +// DBVersion retrieves the stored database version. +func (service *Service) DBVersion() (int, error) { + var data []byte + + err := service.db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(BucketName)) + + value := bucket.Get([]byte(versionKey)) + if value == nil { + return portainer.ErrObjectNotFound + } + + data = make([]byte, len(value)) + copy(data, value) + + return nil + }) + if err != nil { + return 0, err + } + + return strconv.Atoi(string(data)) +} + +// StoreDBVersion store the database version. +func (service *Service) StoreDBVersion(version int) error { + return service.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(BucketName)) + + data := []byte(strconv.Itoa(version)) + return bucket.Put([]byte(versionKey), data) + }) +} diff --git a/api/bolt/version_service.go b/api/bolt/version_service.go deleted file mode 100644 index 1f35cfc40..000000000 --- a/api/bolt/version_service.go +++ /dev/null @@ -1,58 +0,0 @@ -package bolt - -import ( - "strconv" - - "github.com/portainer/portainer" - - "github.com/boltdb/bolt" -) - -// VersionService represents a service to manage stored versions. -type VersionService struct { - store *Store -} - -const ( - dBVersionKey = "DB_VERSION" -) - -// DBVersion retrieves the stored database version. -func (service *VersionService) DBVersion() (int, error) { - var data []byte - err := service.store.db.View(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(versionBucketName)) - value := bucket.Get([]byte(dBVersionKey)) - if value == nil { - return portainer.ErrDBVersionNotFound - } - - data = make([]byte, len(value)) - copy(data, value) - return nil - }) - if err != nil { - return 0, err - } - - dbVersion, err := strconv.Atoi(string(data)) - if err != nil { - return 0, err - } - - return dbVersion, nil -} - -// StoreDBVersion store the database version. -func (service *VersionService) StoreDBVersion(version int) error { - return service.store.db.Update(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(versionBucketName)) - - data := []byte(strconv.Itoa(version)) - err := bucket.Put([]byte(dBVersionKey), data) - if err != nil { - return err - } - return nil - }) -} diff --git a/api/cmd/portainer/main.go b/api/cmd/portainer/main.go index cd4921590..54b7ebb5b 100644 --- a/api/cmd/portainer/main.go +++ b/api/cmd/portainer/main.go @@ -125,13 +125,13 @@ func initStatus(authorizeEndpointMgmt bool, flags *portainer.CLIFlags) *portaine func initDockerHub(dockerHubService portainer.DockerHubService) error { _, err := dockerHubService.DockerHub() - if err == portainer.ErrDockerHubNotFound { + if err == portainer.ErrObjectNotFound { dockerhub := &portainer.DockerHub{ Authentication: false, Username: "", Password: "", } - return dockerHubService.StoreDockerHub(dockerhub) + return dockerHubService.UpdateDockerHub(dockerhub) } else if err != nil { return err } @@ -141,7 +141,7 @@ func initDockerHub(dockerHubService portainer.DockerHubService) error { func initSettings(settingsService portainer.SettingsService, flags *portainer.CLIFlags) error { _, err := settingsService.Settings() - if err == portainer.ErrSettingsNotFound { + if err == portainer.ErrObjectNotFound { settings := &portainer.Settings{ LogoURL: *flags.Logo, DisplayExternalContributors: false, @@ -168,7 +168,7 @@ func initSettings(settingsService portainer.SettingsService, flags *portainer.CL settings.BlackListedLabels = make([]portainer.Pair, 0) } - return settingsService.StoreSettings(settings) + return settingsService.UpdateSettings(settings) } else if err != nil { return err } diff --git a/api/errors.go b/api/errors.go index 4c8823891..9e4eb70e7 100644 --- a/api/errors.go +++ b/api/errors.go @@ -4,14 +4,12 @@ package portainer const ( ErrUnauthorized = Error("Unauthorized") ErrResourceAccessDenied = Error("Access denied to resource") - ErrResourceNotFound = Error("Unable to find resource") - ErrUnsupportedDockerAPI = Error("Unsupported Docker API response") + ErrObjectNotFound = Error("Object not found inside the database") ErrMissingSecurityContext = Error("Unable to find security details in request context") ) // User errors. const ( - ErrUserNotFound = Error("User not found") ErrUserAlreadyExists = Error("User already exists") ErrInvalidUsername = Error("Invalid username. White spaces are not allowed") ErrAdminAlreadyInitialized = Error("An administrator user already exists") @@ -20,26 +18,22 @@ const ( // Team errors. const ( - ErrTeamNotFound = Error("Team not found") ErrTeamAlreadyExists = Error("Team already exists") ) // TeamMembership errors. const ( - ErrTeamMembershipNotFound = Error("Team membership not found") ErrTeamMembershipAlreadyExists = Error("Team membership already exists for this user and team") ) // ResourceControl errors. const ( - ErrResourceControlNotFound = Error("Resource control not found") ErrResourceControlAlreadyExists = Error("A resource control is already applied on this resource") ErrInvalidResourceControlType = Error("Unsupported resource control type") ) // Endpoint errors. const ( - ErrEndpointNotFound = Error("Endpoint not found") ErrEndpointAccessDenied = Error("Access denied to endpoint") ) @@ -50,19 +44,16 @@ const ( // Endpoint group errors. const ( - ErrEndpointGroupNotFound = Error("Endpoint group not found") ErrCannotRemoveDefaultGroup = Error("Cannot remove the default endpoint group") ) // Registry errors. const ( - ErrRegistryNotFound = Error("Registry not found") ErrRegistryAlreadyExists = Error("A registry is already defined for this URL") ) // Stack errors const ( - ErrStackNotFound = Error("Stack not found") ErrStackAlreadyExists = Error("A stack already exists with this name") ErrComposeFileNotFoundInRepository = Error("Unable to find a Compose file in the repository") ErrStackNotExternal = Error("Not an external stack") @@ -79,21 +70,6 @@ const ( ErrEndpointExtensionAlreadyAssociated = Error("This extension is already associated to the endpoint") ) -// Version errors. -const ( - ErrDBVersionNotFound = Error("DB version not found") -) - -// Settings errors. -const ( - ErrSettingsNotFound = Error("Settings not found") -) - -// DockerHub errors. -const ( - ErrDockerHubNotFound = Error("Dockerhub not found") -) - // Crypto errors. const ( ErrCryptoHashFailure = Error("Unable to hash data") diff --git a/api/filesystem/filesystem.go b/api/filesystem/filesystem.go index b0efbfe8f..5bff04852 100644 --- a/api/filesystem/filesystem.go +++ b/api/filesystem/filesystem.go @@ -201,10 +201,21 @@ func (service *Service) WriteJSONToFile(path string, content interface{}) error return ioutil.WriteFile(path, jsonContent, 0644) } +// FileExists checks for the existence of the specified file. +func (service *Service) FileExists(filePath string) (bool, error) { + if _, err := os.Stat(filePath); err != nil { + if os.IsNotExist(err) { + return false, nil + } + return false, err + } + return true, nil +} + // KeyPairFilesExist checks for the existence of the key files. func (service *Service) KeyPairFilesExist() (bool, error) { privateKeyPath := path.Join(service.dataStorePath, PrivateKeyFile) - exists, err := fileExists(privateKeyPath) + exists, err := service.FileExists(privateKeyPath) if err != nil { return false, err } @@ -213,7 +224,7 @@ func (service *Service) KeyPairFilesExist() (bool, error) { } publicKeyPath := path.Join(service.dataStorePath, PublicKeyFile) - exists, err = fileExists(publicKeyPath) + exists, err = service.FileExists(publicKeyPath) if err != nil { return false, err } @@ -307,13 +318,3 @@ func (service *Service) getContentFromPEMFile(filePath string) ([]byte, error) { block, _ := pem.Decode(fileContent) return block.Bytes, nil } - -func fileExists(filePath string) (bool, error) { - if _, err := os.Stat(filePath); err != nil { - if os.IsNotExist(err) { - return false, nil - } - return false, err - } - return true, nil -} diff --git a/api/http/handler/auth/authenticate.go b/api/http/handler/auth/authenticate.go index a5200f7d3..b146352a8 100644 --- a/api/http/handler/auth/authenticate.go +++ b/api/http/handler/auth/authenticate.go @@ -41,7 +41,7 @@ func (handler *Handler) authenticate(w http.ResponseWriter, r *http.Request) *ht } u, err := handler.UserService.UserByUsername(payload.Username) - if err == portainer.ErrUserNotFound { + if err == portainer.ErrObjectNotFound { return &httperror.HandlerError{http.StatusBadRequest, "Invalid credentials", ErrInvalidCredentials} } else if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a user with the specified username from the database", err} diff --git a/api/http/handler/dockerhub/dockerhub_update.go b/api/http/handler/dockerhub/dockerhub_update.go index 9f8f8d201..7bd37bce5 100644 --- a/api/http/handler/dockerhub/dockerhub_update.go +++ b/api/http/handler/dockerhub/dockerhub_update.go @@ -43,7 +43,7 @@ func (handler *Handler) dockerhubUpdate(w http.ResponseWriter, r *http.Request) dockerhub.Password = payload.Password } - err = handler.DockerHubService.StoreDockerHub(dockerhub) + err = handler.DockerHubService.UpdateDockerHub(dockerhub) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the Dockerhub changes inside the database", err} } diff --git a/api/http/handler/endpointgroups/endpointgroup_delete.go b/api/http/handler/endpointgroups/endpointgroup_delete.go index 87c34f94d..01123d850 100644 --- a/api/http/handler/endpointgroups/endpointgroup_delete.go +++ b/api/http/handler/endpointgroups/endpointgroup_delete.go @@ -21,7 +21,7 @@ func (handler *Handler) endpointGroupDelete(w http.ResponseWriter, r *http.Reque } _, err = handler.EndpointGroupService.EndpointGroup(portainer.EndpointGroupID(endpointGroupID)) - if err == portainer.ErrEndpointGroupNotFound { + if err == portainer.ErrObjectNotFound { return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint group with the specified identifier inside the database", err} } else if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint group with the specified identifier inside the database", err} diff --git a/api/http/handler/endpointgroups/endpointgroup_inspect.go b/api/http/handler/endpointgroups/endpointgroup_inspect.go index 8ff26b28f..168d8cb8b 100644 --- a/api/http/handler/endpointgroups/endpointgroup_inspect.go +++ b/api/http/handler/endpointgroups/endpointgroup_inspect.go @@ -17,7 +17,7 @@ func (handler *Handler) endpointGroupInspect(w http.ResponseWriter, r *http.Requ } endpointGroup, err := handler.EndpointGroupService.EndpointGroup(portainer.EndpointGroupID(endpointGroupID)) - if err == portainer.ErrEndpointGroupNotFound { + if err == portainer.ErrObjectNotFound { return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint group with the specified identifier inside the database", err} } else if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint group with the specified identifier inside the database", err} diff --git a/api/http/handler/endpointgroups/endpointgroup_update.go b/api/http/handler/endpointgroups/endpointgroup_update.go index 237e5d9a6..ee31e3066 100644 --- a/api/http/handler/endpointgroups/endpointgroup_update.go +++ b/api/http/handler/endpointgroups/endpointgroup_update.go @@ -34,7 +34,7 @@ func (handler *Handler) endpointGroupUpdate(w http.ResponseWriter, r *http.Reque } endpointGroup, err := handler.EndpointGroupService.EndpointGroup(portainer.EndpointGroupID(endpointGroupID)) - if err == portainer.ErrEndpointGroupNotFound { + if err == portainer.ErrObjectNotFound { return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint group with the specified identifier inside the database", err} } else if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint group with the specified identifier inside the database", err} diff --git a/api/http/handler/endpointgroups/endpointgroup_update_access.go b/api/http/handler/endpointgroups/endpointgroup_update_access.go index a39dbea94..7a3b3038e 100644 --- a/api/http/handler/endpointgroups/endpointgroup_update_access.go +++ b/api/http/handler/endpointgroups/endpointgroup_update_access.go @@ -32,7 +32,7 @@ func (handler *Handler) endpointGroupUpdateAccess(w http.ResponseWriter, r *http } endpointGroup, err := handler.EndpointGroupService.EndpointGroup(portainer.EndpointGroupID(endpointGroupID)) - if err == portainer.ErrEndpointGroupNotFound { + if err == portainer.ErrObjectNotFound { return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint group with the specified identifier inside the database", err} } else if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint group with the specified identifier inside the database", err} diff --git a/api/http/handler/endpointproxy/proxy_azure.go b/api/http/handler/endpointproxy/proxy_azure.go index a9b550da4..dc46bfb3f 100644 --- a/api/http/handler/endpointproxy/proxy_azure.go +++ b/api/http/handler/endpointproxy/proxy_azure.go @@ -17,7 +17,7 @@ func (handler *Handler) proxyRequestsToAzureAPI(w http.ResponseWriter, r *http.R } endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID)) - if err == portainer.ErrEndpointNotFound { + if err == portainer.ErrObjectNotFound { return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint with the specified identifier inside the database", err} } else if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err} diff --git a/api/http/handler/endpointproxy/proxy_docker.go b/api/http/handler/endpointproxy/proxy_docker.go index 352652fc4..01a56e017 100644 --- a/api/http/handler/endpointproxy/proxy_docker.go +++ b/api/http/handler/endpointproxy/proxy_docker.go @@ -17,7 +17,7 @@ func (handler *Handler) proxyRequestsToDockerAPI(w http.ResponseWriter, r *http. } endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID)) - if err == portainer.ErrEndpointNotFound { + if err == portainer.ErrObjectNotFound { return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint with the specified identifier inside the database", err} } else if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err} diff --git a/api/http/handler/endpointproxy/proxy_storidge.go b/api/http/handler/endpointproxy/proxy_storidge.go index 30e85dfda..a582b561d 100644 --- a/api/http/handler/endpointproxy/proxy_storidge.go +++ b/api/http/handler/endpointproxy/proxy_storidge.go @@ -17,7 +17,7 @@ func (handler *Handler) proxyRequestsToStoridgeAPI(w http.ResponseWriter, r *htt } endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID)) - if err == portainer.ErrEndpointNotFound { + if err == portainer.ErrObjectNotFound { return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint with the specified identifier inside the database", err} } else if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err} diff --git a/api/http/handler/endpoints/endpoint_delete.go b/api/http/handler/endpoints/endpoint_delete.go index 4bcb4f14c..40f18f348 100644 --- a/api/http/handler/endpoints/endpoint_delete.go +++ b/api/http/handler/endpoints/endpoint_delete.go @@ -22,7 +22,7 @@ func (handler *Handler) endpointDelete(w http.ResponseWriter, r *http.Request) * } endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID)) - if err == portainer.ErrEndpointNotFound { + if err == portainer.ErrObjectNotFound { return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint with the specified identifier inside the database", err} } else if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err} diff --git a/api/http/handler/endpoints/endpoint_extension_add.go b/api/http/handler/endpoints/endpoint_extension_add.go index e46ba06ce..009083190 100644 --- a/api/http/handler/endpoints/endpoint_extension_add.go +++ b/api/http/handler/endpoints/endpoint_extension_add.go @@ -33,7 +33,7 @@ func (handler *Handler) endpointExtensionAdd(w http.ResponseWriter, r *http.Requ } endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID)) - if err == portainer.ErrEndpointNotFound { + if err == portainer.ErrObjectNotFound { return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint with the specified identifier inside the database", err} } else if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err} diff --git a/api/http/handler/endpoints/endpoint_extension_remove.go b/api/http/handler/endpoints/endpoint_extension_remove.go index bce4fc478..2e238a89e 100644 --- a/api/http/handler/endpoints/endpoint_extension_remove.go +++ b/api/http/handler/endpoints/endpoint_extension_remove.go @@ -17,7 +17,7 @@ func (handler *Handler) endpointExtensionRemove(w http.ResponseWriter, r *http.R } endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID)) - if err == portainer.ErrEndpointNotFound { + if err == portainer.ErrObjectNotFound { return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint with the specified identifier inside the database", err} } else if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err} diff --git a/api/http/handler/endpoints/endpoint_inspect.go b/api/http/handler/endpoints/endpoint_inspect.go index 80b5703a9..82d2cb7b9 100644 --- a/api/http/handler/endpoints/endpoint_inspect.go +++ b/api/http/handler/endpoints/endpoint_inspect.go @@ -17,7 +17,7 @@ func (handler *Handler) endpointInspect(w http.ResponseWriter, r *http.Request) } endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID)) - if err == portainer.ErrEndpointNotFound { + if err == portainer.ErrObjectNotFound { return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint with the specified identifier inside the database", err} } else if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err} diff --git a/api/http/handler/endpoints/endpoint_update.go b/api/http/handler/endpoints/endpoint_update.go index 7532c0450..769267449 100644 --- a/api/http/handler/endpoints/endpoint_update.go +++ b/api/http/handler/endpoints/endpoint_update.go @@ -47,7 +47,7 @@ func (handler *Handler) endpointUpdate(w http.ResponseWriter, r *http.Request) * } endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID)) - if err == portainer.ErrEndpointNotFound { + if err == portainer.ErrObjectNotFound { return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint with the specified identifier inside the database", err} } else if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err} diff --git a/api/http/handler/endpoints/endpoint_update_access.go b/api/http/handler/endpoints/endpoint_update_access.go index 6fb72ba5f..bedf559ff 100644 --- a/api/http/handler/endpoints/endpoint_update_access.go +++ b/api/http/handler/endpoints/endpoint_update_access.go @@ -36,7 +36,7 @@ func (handler *Handler) endpointUpdateAccess(w http.ResponseWriter, r *http.Requ } endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID)) - if err == portainer.ErrEndpointNotFound { + if err == portainer.ErrObjectNotFound { return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint with the specified identifier inside the database", err} } else if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err} diff --git a/api/http/handler/registries/registry_delete.go b/api/http/handler/registries/registry_delete.go index 1fbe5bc04..2e968539b 100644 --- a/api/http/handler/registries/registry_delete.go +++ b/api/http/handler/registries/registry_delete.go @@ -17,7 +17,7 @@ func (handler *Handler) registryDelete(w http.ResponseWriter, r *http.Request) * } _, err = handler.RegistryService.Registry(portainer.RegistryID(registryID)) - if err == portainer.ErrEndpointGroupNotFound { + if err == portainer.ErrObjectNotFound { return &httperror.HandlerError{http.StatusNotFound, "Unable to find a registry with the specified identifier inside the database", err} } else if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a registry with the specified identifier inside the database", err} diff --git a/api/http/handler/registries/registry_inspect.go b/api/http/handler/registries/registry_inspect.go index d78d1969b..a60f24288 100644 --- a/api/http/handler/registries/registry_inspect.go +++ b/api/http/handler/registries/registry_inspect.go @@ -17,7 +17,7 @@ func (handler *Handler) registryInspect(w http.ResponseWriter, r *http.Request) } registry, err := handler.RegistryService.Registry(portainer.RegistryID(registryID)) - if err == portainer.ErrEndpointGroupNotFound { + if err == portainer.ErrObjectNotFound { return &httperror.HandlerError{http.StatusNotFound, "Unable to find a registry with the specified identifier inside the database", err} } else if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a registry with the specified identifier inside the database", err} diff --git a/api/http/handler/registries/registry_update.go b/api/http/handler/registries/registry_update.go index b1a0167bb..1a3743fb5 100644 --- a/api/http/handler/registries/registry_update.go +++ b/api/http/handler/registries/registry_update.go @@ -39,7 +39,7 @@ func (handler *Handler) registryUpdate(w http.ResponseWriter, r *http.Request) * } registry, err := handler.RegistryService.Registry(portainer.RegistryID(registryID)) - if err == portainer.ErrEndpointGroupNotFound { + if err == portainer.ErrObjectNotFound { return &httperror.HandlerError{http.StatusNotFound, "Unable to find a registry with the specified identifier inside the database", err} } else if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a registry with the specified identifier inside the database", err} diff --git a/api/http/handler/registries/registry_update_access.go b/api/http/handler/registries/registry_update_access.go index a8234e95c..a43ccb2f9 100644 --- a/api/http/handler/registries/registry_update_access.go +++ b/api/http/handler/registries/registry_update_access.go @@ -32,7 +32,7 @@ func (handler *Handler) registryUpdateAccess(w http.ResponseWriter, r *http.Requ } registry, err := handler.RegistryService.Registry(portainer.RegistryID(registryID)) - if err == portainer.ErrEndpointGroupNotFound { + if err == portainer.ErrObjectNotFound { return &httperror.HandlerError{http.StatusNotFound, "Unable to find a registry with the specified identifier inside the database", err} } else if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a registry with the specified identifier inside the database", err} diff --git a/api/http/handler/resourcecontrols/resourcecontrol_create.go b/api/http/handler/resourcecontrols/resourcecontrol_create.go index 4dc41e406..baaee3360 100644 --- a/api/http/handler/resourcecontrols/resourcecontrol_create.go +++ b/api/http/handler/resourcecontrols/resourcecontrol_create.go @@ -64,7 +64,7 @@ func (handler *Handler) resourceControlCreate(w http.ResponseWriter, r *http.Req } rc, err := handler.ResourceControlService.ResourceControlByResourceID(payload.ResourceID) - if err != nil && err != portainer.ErrResourceControlNotFound { + if err != nil && err != portainer.ErrObjectNotFound { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve resource controls from the database", err} } if rc != nil { diff --git a/api/http/handler/resourcecontrols/resourcecontrol_delete.go b/api/http/handler/resourcecontrols/resourcecontrol_delete.go index 47a507e9d..48fd8a972 100644 --- a/api/http/handler/resourcecontrols/resourcecontrol_delete.go +++ b/api/http/handler/resourcecontrols/resourcecontrol_delete.go @@ -18,7 +18,7 @@ func (handler *Handler) resourceControlDelete(w http.ResponseWriter, r *http.Req } resourceControl, err := handler.ResourceControlService.ResourceControl(portainer.ResourceControlID(resourceControlID)) - if err == portainer.ErrResourceControlNotFound { + if err == portainer.ErrObjectNotFound { return &httperror.HandlerError{http.StatusNotFound, "Unable to find a resource control with the specified identifier inside the database", err} } else if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a resource control with with the specified identifier inside the database", err} diff --git a/api/http/handler/resourcecontrols/resourcecontrol_update.go b/api/http/handler/resourcecontrols/resourcecontrol_update.go index c2a580cb0..3f3b25799 100644 --- a/api/http/handler/resourcecontrols/resourcecontrol_update.go +++ b/api/http/handler/resourcecontrols/resourcecontrol_update.go @@ -37,7 +37,7 @@ func (handler *Handler) resourceControlUpdate(w http.ResponseWriter, r *http.Req } resourceControl, err := handler.ResourceControlService.ResourceControl(portainer.ResourceControlID(resourceControlID)) - if err == portainer.ErrResourceControlNotFound { + if err == portainer.ErrObjectNotFound { return &httperror.HandlerError{http.StatusNotFound, "Unable to find a resource control with the specified identifier inside the database", err} } else if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a resource control with with the specified identifier inside the database", err} diff --git a/api/http/handler/settings/settings_update.go b/api/http/handler/settings/settings_update.go index defe9cd6b..1e854ec7f 100644 --- a/api/http/handler/settings/settings_update.go +++ b/api/http/handler/settings/settings_update.go @@ -62,7 +62,7 @@ func (handler *Handler) settingsUpdate(w http.ResponseWriter, r *http.Request) * return tlsError } - err = handler.SettingsService.StoreSettings(settings) + err = handler.SettingsService.UpdateSettings(settings) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist settings changes inside the database", err} } diff --git a/api/http/handler/stacks/stack_create.go b/api/http/handler/stacks/stack_create.go index 9a4c37c52..c0b9d9ce0 100644 --- a/api/http/handler/stacks/stack_create.go +++ b/api/http/handler/stacks/stack_create.go @@ -39,7 +39,7 @@ func (handler *Handler) stackCreate(w http.ResponseWriter, r *http.Request) *htt } endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID)) - if err == portainer.ErrEndpointNotFound { + if err == portainer.ErrObjectNotFound { return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint with the specified identifier inside the database", err} } else if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err} diff --git a/api/http/handler/stacks/stack_delete.go b/api/http/handler/stacks/stack_delete.go index c46905585..3844a4d7e 100644 --- a/api/http/handler/stacks/stack_delete.go +++ b/api/http/handler/stacks/stack_delete.go @@ -32,14 +32,14 @@ func (handler *Handler) stackDelete(w http.ResponseWriter, r *http.Request) *htt } stack, err := handler.StackService.Stack(portainer.StackID(id)) - if err == portainer.ErrStackNotFound { + if err == portainer.ErrObjectNotFound { return &httperror.HandlerError{http.StatusNotFound, "Unable to find a stack with the specified identifier inside the database", err} } else if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a stack with the specified identifier inside the database", err} } resourceControl, err := handler.ResourceControlService.ResourceControlByResourceID(stack.Name) - if err != nil && err != portainer.ErrResourceControlNotFound { + if err != nil && err != portainer.ErrObjectNotFound { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a resource control associated to the stack", err} } @@ -68,7 +68,7 @@ func (handler *Handler) stackDelete(w http.ResponseWriter, r *http.Request) *htt } endpoint, err := handler.EndpointService.Endpoint(endpointIdentifier) - if err == portainer.ErrEndpointNotFound { + if err == portainer.ErrObjectNotFound { return &httperror.HandlerError{http.StatusNotFound, "Unable to find the endpoint associated to the stack inside the database", err} } else if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find the endpoint associated to the stack inside the database", err} @@ -94,7 +94,7 @@ func (handler *Handler) stackDelete(w http.ResponseWriter, r *http.Request) *htt func (handler *Handler) deleteExternalStack(r *http.Request, w http.ResponseWriter, stackName string) *httperror.HandlerError { stack, err := handler.StackService.StackByName(stackName) - if err != nil && err != portainer.ErrStackNotFound { + if err != nil && err != portainer.ErrObjectNotFound { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to check for stack existence inside the database", err} } if stack != nil { @@ -107,7 +107,7 @@ func (handler *Handler) deleteExternalStack(r *http.Request, w http.ResponseWrit } endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID)) - if err == portainer.ErrEndpointNotFound { + if err == portainer.ErrObjectNotFound { return &httperror.HandlerError{http.StatusNotFound, "Unable to find the endpoint associated to the stack inside the database", err} } else if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find the endpoint associated to the stack inside the database", err} diff --git a/api/http/handler/stacks/stack_file.go b/api/http/handler/stacks/stack_file.go index 0875ae8e1..a0b644ba0 100644 --- a/api/http/handler/stacks/stack_file.go +++ b/api/http/handler/stacks/stack_file.go @@ -24,14 +24,14 @@ func (handler *Handler) stackFile(w http.ResponseWriter, r *http.Request) *httpe } stack, err := handler.StackService.Stack(portainer.StackID(stackID)) - if err == portainer.ErrStackNotFound { + if err == portainer.ErrObjectNotFound { return &httperror.HandlerError{http.StatusNotFound, "Unable to find a stack with the specified identifier inside the database", err} } else if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a stack with the specified identifier inside the database", err} } resourceControl, err := handler.ResourceControlService.ResourceControlByResourceID(stack.Name) - if err != nil && err != portainer.ErrResourceControlNotFound { + if err != nil && err != portainer.ErrObjectNotFound { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a resource control associated to the stack", err} } diff --git a/api/http/handler/stacks/stack_inspect.go b/api/http/handler/stacks/stack_inspect.go index ec6d99509..28d5030ac 100644 --- a/api/http/handler/stacks/stack_inspect.go +++ b/api/http/handler/stacks/stack_inspect.go @@ -19,14 +19,14 @@ func (handler *Handler) stackInspect(w http.ResponseWriter, r *http.Request) *ht } stack, err := handler.StackService.Stack(portainer.StackID(stackID)) - if err == portainer.ErrStackNotFound { + if err == portainer.ErrObjectNotFound { return &httperror.HandlerError{http.StatusNotFound, "Unable to find a stack with the specified identifier inside the database", err} } else if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a stack with the specified identifier inside the database", err} } resourceControl, err := handler.ResourceControlService.ResourceControlByResourceID(stack.Name) - if err != nil && err != portainer.ErrResourceControlNotFound { + if err != nil && err != portainer.ErrObjectNotFound { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a resource control associated to the stack", err} } diff --git a/api/http/handler/stacks/stack_update.go b/api/http/handler/stacks/stack_update.go index f20f92957..66b2b7f73 100644 --- a/api/http/handler/stacks/stack_update.go +++ b/api/http/handler/stacks/stack_update.go @@ -44,14 +44,14 @@ func (handler *Handler) stackUpdate(w http.ResponseWriter, r *http.Request) *htt } stack, err := handler.StackService.Stack(portainer.StackID(stackID)) - if err == portainer.ErrStackNotFound { + if err == portainer.ErrObjectNotFound { return &httperror.HandlerError{http.StatusNotFound, "Unable to find a stack with the specified identifier inside the database", err} } else if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a stack with the specified identifier inside the database", err} } resourceControl, err := handler.ResourceControlService.ResourceControlByResourceID(stack.Name) - if err != nil && err != portainer.ErrResourceControlNotFound { + if err != nil && err != portainer.ErrObjectNotFound { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a resource control associated to the stack", err} } @@ -78,7 +78,7 @@ func (handler *Handler) stackUpdate(w http.ResponseWriter, r *http.Request) *htt } endpoint, err := handler.EndpointService.Endpoint(stack.EndpointID) - if err == portainer.ErrEndpointNotFound { + if err == portainer.ErrObjectNotFound { return &httperror.HandlerError{http.StatusNotFound, "Unable to find the endpoint associated to the stack inside the database", err} } else if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find the endpoint associated to the stack inside the database", err} diff --git a/api/http/handler/teammemberships/teammembership_delete.go b/api/http/handler/teammemberships/teammembership_delete.go index 577f3be7d..a1263745f 100644 --- a/api/http/handler/teammemberships/teammembership_delete.go +++ b/api/http/handler/teammemberships/teammembership_delete.go @@ -18,7 +18,7 @@ func (handler *Handler) teamMembershipDelete(w http.ResponseWriter, r *http.Requ } membership, err := handler.TeamMembershipService.TeamMembership(portainer.TeamMembershipID(membershipID)) - if err == portainer.ErrTeamMembershipNotFound { + if err == portainer.ErrObjectNotFound { return &httperror.HandlerError{http.StatusNotFound, "Unable to find a team membership with the specified identifier inside the database", err} } else if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a team membership with the specified identifier inside the database", err} diff --git a/api/http/handler/teammemberships/teammembership_update.go b/api/http/handler/teammemberships/teammembership_update.go index 953ca5618..6d08bc90a 100644 --- a/api/http/handler/teammemberships/teammembership_update.go +++ b/api/http/handler/teammemberships/teammembership_update.go @@ -52,7 +52,7 @@ func (handler *Handler) teamMembershipUpdate(w http.ResponseWriter, r *http.Requ } membership, err := handler.TeamMembershipService.TeamMembership(portainer.TeamMembershipID(membershipID)) - if err == portainer.ErrTeamMembershipNotFound { + if err == portainer.ErrObjectNotFound { return &httperror.HandlerError{http.StatusNotFound, "Unable to find a team membership with the specified identifier inside the database", err} } else if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a team membership with the specified identifier inside the database", err} diff --git a/api/http/handler/teams/team_create.go b/api/http/handler/teams/team_create.go index 49dc6f093..d865e56c5 100644 --- a/api/http/handler/teams/team_create.go +++ b/api/http/handler/teams/team_create.go @@ -29,7 +29,7 @@ func (handler *Handler) teamCreate(w http.ResponseWriter, r *http.Request) *http } team, err := handler.TeamService.TeamByName(payload.Name) - if err != nil && err != portainer.ErrTeamNotFound { + if err != nil && err != portainer.ErrObjectNotFound { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve teams from the database", err} } if team != nil { diff --git a/api/http/handler/teams/team_delete.go b/api/http/handler/teams/team_delete.go index ab805248b..623c29c4c 100644 --- a/api/http/handler/teams/team_delete.go +++ b/api/http/handler/teams/team_delete.go @@ -17,7 +17,7 @@ func (handler *Handler) teamDelete(w http.ResponseWriter, r *http.Request) *http } _, err = handler.TeamService.Team(portainer.TeamID(teamID)) - if err == portainer.ErrTeamNotFound { + if err == portainer.ErrObjectNotFound { return &httperror.HandlerError{http.StatusNotFound, "Unable to find a team with the specified identifier inside the database", err} } else if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a team with the specified identifier inside the database", err} diff --git a/api/http/handler/teams/team_inspect.go b/api/http/handler/teams/team_inspect.go index b6c6ab1a6..4030a391e 100644 --- a/api/http/handler/teams/team_inspect.go +++ b/api/http/handler/teams/team_inspect.go @@ -27,7 +27,7 @@ func (handler *Handler) teamInspect(w http.ResponseWriter, r *http.Request) *htt } team, err := handler.TeamService.Team(portainer.TeamID(teamID)) - if err == portainer.ErrTeamNotFound { + if err == portainer.ErrObjectNotFound { return &httperror.HandlerError{http.StatusNotFound, "Unable to find a team with the specified identifier inside the database", err} } else if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a team with the specified identifier inside the database", err} diff --git a/api/http/handler/teams/team_update.go b/api/http/handler/teams/team_update.go index ea80f0dd5..8c0961c31 100644 --- a/api/http/handler/teams/team_update.go +++ b/api/http/handler/teams/team_update.go @@ -31,7 +31,7 @@ func (handler *Handler) teamUpdate(w http.ResponseWriter, r *http.Request) *http } team, err := handler.TeamService.Team(portainer.TeamID(teamID)) - if err == portainer.ErrTeamNotFound { + if err == portainer.ErrObjectNotFound { return &httperror.HandlerError{http.StatusNotFound, "Unable to find a team with the specified identifier inside the database", err} } else if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a team with the specified identifier inside the database", err} diff --git a/api/http/handler/users/admin_check.go b/api/http/handler/users/admin_check.go index 1120c957a..4d7ba233a 100644 --- a/api/http/handler/users/admin_check.go +++ b/api/http/handler/users/admin_check.go @@ -16,7 +16,7 @@ func (handler *Handler) adminCheck(w http.ResponseWriter, r *http.Request) *http } if len(users) == 0 { - return &httperror.HandlerError{http.StatusNotFound, "No administrator account found inside the database", portainer.ErrUserNotFound} + return &httperror.HandlerError{http.StatusNotFound, "No administrator account found inside the database", portainer.ErrObjectNotFound} } return response.Empty(w) diff --git a/api/http/handler/users/user_create.go b/api/http/handler/users/user_create.go index 7efba6d25..9fb4cddda 100644 --- a/api/http/handler/users/user_create.go +++ b/api/http/handler/users/user_create.go @@ -50,7 +50,7 @@ func (handler *Handler) userCreate(w http.ResponseWriter, r *http.Request) *http } user, err := handler.UserService.UserByUsername(payload.Username) - if err != nil && err != portainer.ErrUserNotFound { + if err != nil && err != portainer.ErrObjectNotFound { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve users from the database", err} } if user != nil { diff --git a/api/http/handler/users/user_delete.go b/api/http/handler/users/user_delete.go index 5723bf387..c183df7a7 100644 --- a/api/http/handler/users/user_delete.go +++ b/api/http/handler/users/user_delete.go @@ -27,7 +27,7 @@ func (handler *Handler) userDelete(w http.ResponseWriter, r *http.Request) *http } _, err = handler.UserService.User(portainer.UserID(userID)) - if err == portainer.ErrUserNotFound { + if err == portainer.ErrObjectNotFound { return &httperror.HandlerError{http.StatusNotFound, "Unable to find a user with the specified identifier inside the database", err} } else if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a user with the specified identifier inside the database", err} diff --git a/api/http/handler/users/user_inspect.go b/api/http/handler/users/user_inspect.go index c74d170d6..9583c833c 100644 --- a/api/http/handler/users/user_inspect.go +++ b/api/http/handler/users/user_inspect.go @@ -17,7 +17,7 @@ func (handler *Handler) userInspect(w http.ResponseWriter, r *http.Request) *htt } user, err := handler.UserService.User(portainer.UserID(userID)) - if err == portainer.ErrUserNotFound { + if err == portainer.ErrObjectNotFound { return &httperror.HandlerError{http.StatusNotFound, "Unable to find a user with the specified identifier inside the database", err} } else if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a user with the specified identifier inside the database", err} diff --git a/api/http/handler/users/user_password.go b/api/http/handler/users/user_password.go index 073c10360..d50f88879 100644 --- a/api/http/handler/users/user_password.go +++ b/api/http/handler/users/user_password.go @@ -41,7 +41,7 @@ func (handler *Handler) userPassword(w http.ResponseWriter, r *http.Request) *ht var password = payload.Password u, err := handler.UserService.User(portainer.UserID(userID)) - if err == portainer.ErrUserNotFound { + if err == portainer.ErrObjectNotFound { return &httperror.HandlerError{http.StatusNotFound, "Unable to find a user with the specified identifier inside the database", err} } else if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a user with the specified identifier inside the database", err} diff --git a/api/http/handler/users/user_update.go b/api/http/handler/users/user_update.go index 4a01d0e37..a8b6c8b1a 100644 --- a/api/http/handler/users/user_update.go +++ b/api/http/handler/users/user_update.go @@ -49,7 +49,7 @@ func (handler *Handler) userUpdate(w http.ResponseWriter, r *http.Request) *http } user, err := handler.UserService.User(portainer.UserID(userID)) - if err == portainer.ErrUserNotFound { + if err == portainer.ErrObjectNotFound { return &httperror.HandlerError{http.StatusNotFound, "Unable to find a user with the specified identifier inside the database", err} } else if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a user with the specified identifier inside the database", err} diff --git a/api/http/handler/websocket/websocket_exec.go b/api/http/handler/websocket/websocket_exec.go index cbb9fbbf4..6e46084a6 100644 --- a/api/http/handler/websocket/websocket_exec.go +++ b/api/http/handler/websocket/websocket_exec.go @@ -52,7 +52,7 @@ func (handler *Handler) websocketExec(w http.ResponseWriter, r *http.Request) *h } endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID)) - if err == portainer.ErrEndpointNotFound { + if err == portainer.ErrObjectNotFound { return &httperror.HandlerError{http.StatusNotFound, "Unable to find the endpoint associated to the stack inside the database", err} } else if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find the endpoint associated to the stack inside the database", err} diff --git a/api/http/security/bouncer.go b/api/http/security/bouncer.go index 1327503eb..798d37f7b 100644 --- a/api/http/security/bouncer.go +++ b/api/http/security/bouncer.go @@ -185,7 +185,7 @@ func (bouncer *RequestBouncer) mwCheckAuthentication(next http.Handler) http.Han } _, err = bouncer.userService.User(tokenData.ID) - if err != nil && err == portainer.ErrUserNotFound { + if err != nil && err == portainer.ErrObjectNotFound { httperror.WriteError(w, http.StatusUnauthorized, "Unauthorized", portainer.ErrUnauthorized) return } else if err != nil { diff --git a/api/portainer.go b/api/portainer.go index 37f1e5e19..38cea152f 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -379,13 +379,13 @@ type ( // DockerHubService represents a service for managing the DockerHub object. DockerHubService interface { DockerHub() (*DockerHub, error) - StoreDockerHub(registry *DockerHub) error + UpdateDockerHub(registry *DockerHub) error } // SettingsService represents a service for managing application settings. SettingsService interface { Settings() (*Settings, error) - StoreSettings(settings *Settings) error + UpdateSettings(settings *Settings) error } // VersionService represents a service for managing version data. @@ -447,6 +447,7 @@ type ( StoreKeyPair(private, public []byte, privatePEMHeader, publicPEMHeader string) error LoadKeyPair() ([]byte, []byte, error) WriteJSONToFile(path string, content interface{}) error + FileExists(path string) (bool, error) } // GitService represents a service for managing Git. From 9cab961d87e02ef7a6cd69ec20428211a1e31cb7 Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Tue, 19 Jun 2018 14:20:34 +0300 Subject: [PATCH 25/37] fix(about): fix missing widget headers --- app/portainer/views/about/about.html | 6 +++--- app/portainer/views/support/support.html | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/portainer/views/about/about.html b/app/portainer/views/about/about.html index d7b249c65..e95dc837f 100644 --- a/app/portainer/views/about/about.html +++ b/app/portainer/views/about/about.html @@ -19,7 +19,7 @@
- +

@@ -48,7 +48,7 @@

- +

@@ -81,7 +81,7 @@

- +
Portainer has full support for Docker >=1.10 and partial support for Docker 1.9 (some features may not be available). diff --git a/app/portainer/views/support/support.html b/app/portainer/views/support/support.html index fd76f5297..b687d52de 100644 --- a/app/portainer/views/support/support.html +++ b/app/portainer/views/support/support.html @@ -9,7 +9,7 @@
- +

From 0da9e564b99d6f120f77388f99df2b97c501ac0b Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Tue, 19 Jun 2018 17:28:40 +0200 Subject: [PATCH 26/37] feat(stacks): add the ability to migrate stacks to another endpoint (#1976) * feat(stacks): add the ability to migrate stacks to another endpoint * feat(stack-details): do not redirect to alternate endpoint after migration * fix(api): fix merge conflicts * feat(stack-details): add a modal to confirm stack migration --- api/http/handler/stacks/handler.go | 2 + api/http/handler/stacks/stack_migrate.go | 132 +++++++++ app/__module.js | 1 + app/docker/rest/swarm.js | 2 +- .../endpoint-selector/endpoint-selector.js | 4 +- .../endpoint-selector/endpointSelector.html | 35 +-- .../endpointSelectorController.js | 19 +- .../sidebar-endpoint-selector.js | 9 + .../sidebarEndpointSelector.html | 27 ++ .../sidebarEndpointSelectorController.js | 34 +++ app/portainer/models/stack.js | 1 + app/portainer/rest/stack.js | 3 +- app/portainer/services/api/stackService.js | 49 +++- app/portainer/views/sidebar/sidebar.html | 4 +- app/portainer/views/stacks/edit/stack.html | 266 +++++++++++------- .../views/stacks/edit/stackController.js | 92 +++++- package.json | 1 + vendor.yml | 2 + yarn.lock | 4 + 19 files changed, 528 insertions(+), 159 deletions(-) create mode 100644 api/http/handler/stacks/stack_migrate.go create mode 100644 app/portainer/components/sidebar-endpoint-selector/sidebar-endpoint-selector.js create mode 100644 app/portainer/components/sidebar-endpoint-selector/sidebarEndpointSelector.html create mode 100644 app/portainer/components/sidebar-endpoint-selector/sidebarEndpointSelectorController.js diff --git a/api/http/handler/stacks/handler.go b/api/http/handler/stacks/handler.go index d907d52d2..ba231ebfe 100644 --- a/api/http/handler/stacks/handler.go +++ b/api/http/handler/stacks/handler.go @@ -47,5 +47,7 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler { bouncer.RestrictedAccess(httperror.LoggerHandler(h.stackUpdate))).Methods(http.MethodPut) h.Handle("/stacks/{id}/file", bouncer.RestrictedAccess(httperror.LoggerHandler(h.stackFile))).Methods(http.MethodGet) + h.Handle("/stacks/{id}/migrate", + bouncer.RestrictedAccess(httperror.LoggerHandler(h.stackMigrate))).Methods(http.MethodPost) return h } diff --git a/api/http/handler/stacks/stack_migrate.go b/api/http/handler/stacks/stack_migrate.go new file mode 100644 index 000000000..33d410b7b --- /dev/null +++ b/api/http/handler/stacks/stack_migrate.go @@ -0,0 +1,132 @@ +package stacks + +import ( + "net/http" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/proxy" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" + "github.com/portainer/portainer/http/security" +) + +type stackMigratePayload struct { + EndpointID int + SwarmID string +} + +func (payload *stackMigratePayload) Validate(r *http.Request) error { + if payload.EndpointID == 0 { + return portainer.Error("Invalid endpoint identifier. Must be a positive number") + } + return nil +} + +// POST request on /api/stacks/:id/migrate +func (handler *Handler) stackMigrate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + stackID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid stack identifier route variable", err} + } + + var payload stackMigratePayload + err = request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + stack, err := handler.StackService.Stack(portainer.StackID(stackID)) + if err == portainer.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find a stack with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a stack with the specified identifier inside the database", err} + } + + resourceControl, err := handler.ResourceControlService.ResourceControlByResourceID(stack.Name) + if err != nil && err != portainer.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a resource control associated to the stack", err} + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} + } + + if resourceControl != nil { + if !securityContext.IsAdmin && !proxy.CanAccessStack(stack, resourceControl, securityContext.UserID, securityContext.UserMemberships) { + return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", portainer.ErrResourceAccessDenied} + } + } + + endpoint, err := handler.EndpointService.Endpoint(stack.EndpointID) + if err == portainer.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find the endpoint associated to the stack inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find the endpoint associated to the stack inside the database", err} + } + + targetEndpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(payload.EndpointID)) + if err == portainer.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err} + } + + stack.EndpointID = portainer.EndpointID(payload.EndpointID) + if payload.SwarmID != "" { + stack.SwarmID = payload.SwarmID + } + + migrationError := handler.migrateStack(r, stack, targetEndpoint) + if migrationError != nil { + return migrationError + } + + err = handler.deleteStack(stack, endpoint) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, err.Error(), err} + } + + err = handler.StackService.UpdateStack(stack.ID, stack) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the stack changes inside the database", err} + } + + return response.JSON(w, stack) +} + +func (handler *Handler) migrateStack(r *http.Request, stack *portainer.Stack, next *portainer.Endpoint) *httperror.HandlerError { + if stack.Type == portainer.DockerSwarmStack { + return handler.migrateSwarmStack(r, stack, next) + } + return handler.migrateComposeStack(r, stack, next) +} + +func (handler *Handler) migrateComposeStack(r *http.Request, stack *portainer.Stack, next *portainer.Endpoint) *httperror.HandlerError { + config, configErr := handler.createComposeDeployConfig(r, stack, next) + if configErr != nil { + return configErr + } + + err := handler.deployComposeStack(config) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, err.Error(), err} + } + + return nil +} + +func (handler *Handler) migrateSwarmStack(r *http.Request, stack *portainer.Stack, next *portainer.Endpoint) *httperror.HandlerError { + config, configErr := handler.createSwarmDeployConfig(r, stack, next, true) + if configErr != nil { + return configErr + } + + err := handler.deploySwarmStack(config) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, err.Error(), err} + } + + return nil +} diff --git a/app/__module.js b/app/__module.js index a1c438489..e1cf659fa 100644 --- a/app/__module.js +++ b/app/__module.js @@ -1,6 +1,7 @@ angular.module('portainer', [ 'ui.bootstrap', 'ui.router', + 'ui.select', 'isteven-multi-select', 'ngCookies', 'ngSanitize', diff --git a/app/docker/rest/swarm.js b/app/docker/rest/swarm.js index b8f83fd93..de4cc85a4 100644 --- a/app/docker/rest/swarm.js +++ b/app/docker/rest/swarm.js @@ -5,6 +5,6 @@ angular.module('portainer.docker') endpointId: EndpointProvider.endpointID }, { - get: {method: 'GET'} + get: { method: 'GET' } }); }]); diff --git a/app/portainer/components/endpoint-selector/endpoint-selector.js b/app/portainer/components/endpoint-selector/endpoint-selector.js index cb4ddc207..82b54e212 100644 --- a/app/portainer/components/endpoint-selector/endpoint-selector.js +++ b/app/portainer/components/endpoint-selector/endpoint-selector.js @@ -2,8 +2,8 @@ angular.module('portainer.app').component('endpointSelector', { templateUrl: 'app/portainer/components/endpoint-selector/endpointSelector.html', controller: 'EndpointSelectorController', bindings: { + 'model': '=', 'endpoints': '<', - 'groups': '<', - 'selectEndpoint': '<' + 'groups': '<' } }); diff --git a/app/portainer/components/endpoint-selector/endpointSelector.html b/app/portainer/components/endpoint-selector/endpointSelector.html index 79332e2b0..c3800195f 100644 --- a/app/portainer/components/endpoint-selector/endpointSelector.html +++ b/app/portainer/components/endpoint-selector/endpointSelector.html @@ -1,27 +1,8 @@ -

-
- -
-
-
- - -
-
- - -
-
-
+ + + {{ $select.selected.Name }} + + + {{ endpoint.Name }} + + diff --git a/app/portainer/components/endpoint-selector/endpointSelectorController.js b/app/portainer/components/endpoint-selector/endpointSelectorController.js index fab3193cc..23770e71f 100644 --- a/app/portainer/components/endpoint-selector/endpointSelectorController.js +++ b/app/portainer/components/endpoint-selector/endpointSelectorController.js @@ -2,21 +2,22 @@ angular.module('portainer.app') .controller('EndpointSelectorController', function () { var ctrl = this; - this.state = { - show: false, - selectedGroup: null, - selectedEndpoint: null + this.sortGroups = function(groups) { + return _.sortBy(groups, ['name']); }; - this.selectGroup = function() { - this.availableEndpoints = this.endpoints.filter(function f(endpoint) { - return endpoint.GroupId === ctrl.state.selectedGroup.Id; - }); + this.groupEndpoints = function(endpoint) { + for (var i = 0; i < ctrl.availableGroups.length; i++) { + var group = ctrl.availableGroups[i]; + + if (endpoint.GroupId === group.Id) { + return group.Name; + } + } }; this.$onInit = function() { this.availableGroups = filterEmptyGroups(this.groups, this.endpoints); - this.availableEndpoints = this.endpoints; }; function filterEmptyGroups(groups, endpoints) { diff --git a/app/portainer/components/sidebar-endpoint-selector/sidebar-endpoint-selector.js b/app/portainer/components/sidebar-endpoint-selector/sidebar-endpoint-selector.js new file mode 100644 index 000000000..32f5ec116 --- /dev/null +++ b/app/portainer/components/sidebar-endpoint-selector/sidebar-endpoint-selector.js @@ -0,0 +1,9 @@ +angular.module('portainer.app').component('sidebarEndpointSelector', { + templateUrl: 'app/portainer/components/sidebar-endpoint-selector/sidebarEndpointSelector.html', + controller: 'SidebarEndpointSelectorController', + bindings: { + 'endpoints': '<', + 'groups': '<', + 'selectEndpoint': '<' + } +}); diff --git a/app/portainer/components/sidebar-endpoint-selector/sidebarEndpointSelector.html b/app/portainer/components/sidebar-endpoint-selector/sidebarEndpointSelector.html new file mode 100644 index 000000000..79332e2b0 --- /dev/null +++ b/app/portainer/components/sidebar-endpoint-selector/sidebarEndpointSelector.html @@ -0,0 +1,27 @@ +
+
+ +
+
+
+ + +
+
+ + +
+
+
diff --git a/app/portainer/components/sidebar-endpoint-selector/sidebarEndpointSelectorController.js b/app/portainer/components/sidebar-endpoint-selector/sidebarEndpointSelectorController.js new file mode 100644 index 000000000..ff8d54a57 --- /dev/null +++ b/app/portainer/components/sidebar-endpoint-selector/sidebarEndpointSelectorController.js @@ -0,0 +1,34 @@ +angular.module('portainer.app') +.controller('SidebarEndpointSelectorController', function () { + var ctrl = this; + + this.state = { + show: false, + selectedGroup: null, + selectedEndpoint: null + }; + + this.selectGroup = function() { + this.availableEndpoints = this.endpoints.filter(function f(endpoint) { + return endpoint.GroupId === ctrl.state.selectedGroup.Id; + }); + }; + + this.$onInit = function() { + this.availableGroups = filterEmptyGroups(this.groups, this.endpoints); + this.availableEndpoints = this.endpoints; + }; + + function filterEmptyGroups(groups, endpoints) { + return groups.filter(function f(group) { + for (var i = 0; i < endpoints.length; i++) { + + var endpoint = endpoints[i]; + if (endpoint.GroupId === group.Id) { + return true; + } + } + return false; + }); + } +}); diff --git a/app/portainer/models/stack.js b/app/portainer/models/stack.js index 06666b588..813027f97 100644 --- a/app/portainer/models/stack.js +++ b/app/portainer/models/stack.js @@ -4,6 +4,7 @@ function StackViewModel(data) { this.Name = data.Name; this.Checked = false; this.EndpointId = data.EndpointId; + this.SwarmId = data.SwarmId; this.Env = data.Env ? data.Env : []; if (data.ResourceControl && data.ResourceControl.Id !== 0) { this.ResourceControl = new ResourceControlViewModel(data.ResourceControl); diff --git a/app/portainer/rest/stack.js b/app/portainer/rest/stack.js index 3d1efc5af..d1de2da1e 100644 --- a/app/portainer/rest/stack.js +++ b/app/portainer/rest/stack.js @@ -8,6 +8,7 @@ angular.module('portainer.app') create: { method: 'POST', ignoreLoadingBar: true }, update: { method: 'PUT', params: { id: '@id' }, ignoreLoadingBar: true }, remove: { method: 'DELETE', params: { id: '@id', external: '@external', endpointId: '@endpointId' } }, - getStackFile: { method: 'GET', params: { id : '@id', action: 'file' } } + getStackFile: { method: 'GET', params: { id : '@id', action: 'file' } }, + migrate: { method: 'POST', params: { id : '@id', action: 'migrate' }, ignoreLoadingBar: true } }); }]); diff --git a/app/portainer/services/api/stackService.js b/app/portainer/services/api/stackService.js index 66f2fd6bb..8a70bfc0c 100644 --- a/app/portainer/services/api/stackService.js +++ b/app/portainer/services/api/stackService.js @@ -1,6 +1,6 @@ angular.module('portainer.app') -.factory('StackService', ['$q', 'Stack', 'ResourceControlService', 'FileUploadService', 'StackHelper', 'ServiceService', 'ContainerService', 'SwarmService', -function StackServiceFactory($q, Stack, ResourceControlService, FileUploadService, StackHelper, ServiceService, ContainerService, SwarmService) { +.factory('StackService', ['$q', 'Stack', 'ResourceControlService', 'FileUploadService', 'StackHelper', 'ServiceService', 'ContainerService', 'SwarmService', 'EndpointProvider', +function StackServiceFactory($q, Stack, ResourceControlService, FileUploadService, StackHelper, ServiceService, ContainerService, SwarmService, EndpointProvider) { 'use strict'; var service = {}; @@ -33,6 +33,51 @@ function StackServiceFactory($q, Stack, ResourceControlService, FileUploadServic return deferred.promise; }; + service.migrateSwarmStack = function(stack, targetEndpointId) { + var deferred = $q.defer(); + + EndpointProvider.setEndpointID(targetEndpointId); + + SwarmService.swarm() + .then(function success(data) { + var swarm = data; + if (swarm.Id === stack.SwarmId) { + deferred.reject({ msg: 'Target endpoint is located in the same Swarm cluster as the current endpoint', err: null }); + return; + } + + return Stack.migrate({ id: stack.Id }, { EndpointID: targetEndpointId, SwarmID: swarm.Id }).$promise; + }) + .then(function success(data) { + deferred.resolve(); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to migrate stack', err: err }); + }) + .finally(function final() { + EndpointProvider.setEndpointID(stack.EndpointId); + }); + + return deferred.promise; + }; + + service.migrateComposeStack = function(stack, targetEndpointId) { + var deferred = $q.defer(); + + EndpointProvider.setEndpointID(targetEndpointId); + + Stack.migrate({ id: stack.Id }, { EndpointID: targetEndpointId }).$promise + .then(function success(data) { + deferred.resolve(); + }) + .catch(function error(err) { + EndpointProvider.setEndpointID(stack.EndpointId); + deferred.reject({ msg: 'Unable to migrate stack', err: err }); + }); + + return deferred.promise; + }; + service.stacks = function(compose, swarm, endpointId) { var deferred = $q.defer(); diff --git a/app/portainer/views/sidebar/sidebar.html b/app/portainer/views/sidebar/sidebar.html index c9450da0d..32c5bed32 100644 --- a/app/portainer/views/sidebar/sidebar.html +++ b/app/portainer/views/sidebar/sidebar.html @@ -9,11 +9,11 @@
- + {{ p.PublishedPort }}:{{ p.TargetPort }} - From 413ab44dc08a02ed28211f8a14016285cbb45d6f Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Wed, 20 Jun 2018 17:08:31 +0300 Subject: [PATCH 30/37] refactor(stacks): remove unused component --- .../stackServicesDatatable.html | 115 ------------------ .../stackServicesDatatable.js | 16 --- 2 files changed, 131 deletions(-) delete mode 100644 app/portainer/components/datatables/stack-services-datatable/stackServicesDatatable.html delete mode 100644 app/portainer/components/datatables/stack-services-datatable/stackServicesDatatable.js diff --git a/app/portainer/components/datatables/stack-services-datatable/stackServicesDatatable.html b/app/portainer/components/datatables/stack-services-datatable/stackServicesDatatable.html deleted file mode 100644 index b1ea5c372..000000000 --- a/app/portainer/components/datatables/stack-services-datatable/stackServicesDatatable.html +++ /dev/null @@ -1,115 +0,0 @@ -
- - -
-
- {{ $ctrl.titleText }} -
-
- - Search - -
-
- -
- - - - - - - - - - - - - - - - - - - - - - - - - -
- - Name - - - - - - Image - - - - - - Scheduling Mode - - - - - - Published Ports - - - - - - Last Update - - - -
{{ item.Name }}{{ item.Image | hideshasum }} - {{ item.Mode }} - {{ item.Tasks | runningtaskscount }} / {{ item.Mode === 'replicated' ? item.Replicas : ($ctrl.nodes | availablenodecount) }} - - - Scale - - - - - - - - - - {{ p.PublishedPort }}:{{ p.TargetPort }} - - - - {{ item.UpdatedAt | getisodate }}
Loading...
No service available.
-
- -
-
-
diff --git a/app/portainer/components/datatables/stack-services-datatable/stackServicesDatatable.js b/app/portainer/components/datatables/stack-services-datatable/stackServicesDatatable.js deleted file mode 100644 index 59c437d7f..000000000 --- a/app/portainer/components/datatables/stack-services-datatable/stackServicesDatatable.js +++ /dev/null @@ -1,16 +0,0 @@ -angular.module('portainer.app').component('stackServicesDatatable', { - templateUrl: 'app/portainer/components/datatables/stack-services-datatable/stackServicesDatatable.html', - controller: 'GenericDatatableController', - bindings: { - titleText: '@', - titleIcon: '@', - dataset: '<', - tableKey: '@', - orderBy: '@', - reverseOrder: '<', - nodes: '<', - scaleAction: '<', - publicUrl: '<', - showTextFilter: '<' - } -}); From 115c1608b927466aebdbe77e906a3a811e6ac89a Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Wed, 20 Jun 2018 18:20:16 +0300 Subject: [PATCH 31/37] feat(libcompose): set RemoveVolume to false --- api/libcompose/compose_stack.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/libcompose/compose_stack.go b/api/libcompose/compose_stack.go index 33d582f90..5cf1264b6 100644 --- a/api/libcompose/compose_stack.go +++ b/api/libcompose/compose_stack.go @@ -87,5 +87,5 @@ func (manager *ComposeStackManager) Down(stack *portainer.Stack, endpoint *porta return err } - return proj.Down(context.Background(), options.Down{RemoveVolume: true, RemoveOrphans: true}) + return proj.Down(context.Background(), options.Down{RemoveVolume: false, RemoveOrphans: true}) } From 48f963398fc861dc7e103671b6f3f9715e8489e1 Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Wed, 20 Jun 2018 20:43:39 +0300 Subject: [PATCH 32/37] refactor(api): remove useless log.printf statement --- api/http/proxy/docker_transport.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/api/http/proxy/docker_transport.go b/api/http/proxy/docker_transport.go index 8fb20d3f0..9effb54d8 100644 --- a/api/http/proxy/docker_transport.go +++ b/api/http/proxy/docker_transport.go @@ -3,7 +3,6 @@ package proxy import ( "encoding/base64" "encoding/json" - "log" "net/http" "path" "regexp" @@ -304,7 +303,6 @@ func (p *proxyTransport) replaceRegistryAuthenticationHeader(request *http.Reque } authenticationHeader := createRegistryAuthenticationHeader(originalHeaderData.Serveraddress, accessContext) - log.Printf("Header: %+v", authenticationHeader) headerData, err := json.Marshal(authenticationHeader) if err != nil { From a5bd2743f364003d4c7d4933ef8f9e08894af59c Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Wed, 20 Jun 2018 20:55:00 +0300 Subject: [PATCH 33/37] fix(stacks): fix an issue with stack update --- api/http/handler/stacks/create_compose_stack.go | 7 +++++-- api/http/handler/stacks/create_swarm_stack.go | 7 +++++-- api/http/handler/stacks/stack_update.go | 7 +++++-- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/api/http/handler/stacks/create_compose_stack.go b/api/http/handler/stacks/create_compose_stack.go index 2738e255b..26e1c6217 100644 --- a/api/http/handler/stacks/create_compose_stack.go +++ b/api/http/handler/stacks/create_compose_stack.go @@ -2,6 +2,7 @@ package stacks import ( "net/http" + "strconv" "strings" "github.com/asaskevich/govalidator" @@ -55,7 +56,8 @@ func (handler *Handler) createComposeStackFromFileContent(w http.ResponseWriter, EntryPoint: filesystem.ComposeFileDefaultName, } - projectPath, err := handler.FileService.StoreStackFileFromBytes(string(stack.ID), stack.EntryPoint, []byte(payload.StackFileContent)) + stackFolder := strconv.Itoa(int(stack.ID)) + projectPath, err := handler.FileService.StoreStackFileFromBytes(stackFolder, stack.EntryPoint, []byte(payload.StackFileContent)) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist Compose file on disk", err} } @@ -220,7 +222,8 @@ func (handler *Handler) createComposeStackFromFileUpload(w http.ResponseWriter, EntryPoint: filesystem.ComposeFileDefaultName, } - projectPath, err := handler.FileService.StoreStackFileFromBytes(string(stack.ID), stack.EntryPoint, []byte(payload.StackFileContent)) + stackFolder := strconv.Itoa(int(stack.ID)) + projectPath, err := handler.FileService.StoreStackFileFromBytes(stackFolder, stack.EntryPoint, []byte(payload.StackFileContent)) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist Compose file on disk", err} } diff --git a/api/http/handler/stacks/create_swarm_stack.go b/api/http/handler/stacks/create_swarm_stack.go index aeb99e3f5..6fee54221 100644 --- a/api/http/handler/stacks/create_swarm_stack.go +++ b/api/http/handler/stacks/create_swarm_stack.go @@ -2,6 +2,7 @@ package stacks import ( "net/http" + "strconv" "strings" "github.com/asaskevich/govalidator" @@ -62,7 +63,8 @@ func (handler *Handler) createSwarmStackFromFileContent(w http.ResponseWriter, r Env: payload.Env, } - projectPath, err := handler.FileService.StoreStackFileFromBytes(string(stack.ID), stack.EntryPoint, []byte(payload.StackFileContent)) + stackFolder := strconv.Itoa(int(stack.ID)) + projectPath, err := handler.FileService.StoreStackFileFromBytes(stackFolder, stack.EntryPoint, []byte(payload.StackFileContent)) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist Compose file on disk", err} } @@ -250,7 +252,8 @@ func (handler *Handler) createSwarmStackFromFileUpload(w http.ResponseWriter, r Env: payload.Env, } - projectPath, err := handler.FileService.StoreStackFileFromBytes(string(stack.ID), stack.EntryPoint, []byte(payload.StackFileContent)) + stackFolder := strconv.Itoa(int(stack.ID)) + projectPath, err := handler.FileService.StoreStackFileFromBytes(stackFolder, stack.EntryPoint, []byte(payload.StackFileContent)) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist Compose file on disk", err} } diff --git a/api/http/handler/stacks/stack_update.go b/api/http/handler/stacks/stack_update.go index 66b2b7f73..6ffef9112 100644 --- a/api/http/handler/stacks/stack_update.go +++ b/api/http/handler/stacks/stack_update.go @@ -2,6 +2,7 @@ package stacks import ( "net/http" + "strconv" "github.com/asaskevich/govalidator" "github.com/portainer/portainer" @@ -111,7 +112,8 @@ func (handler *Handler) updateComposeStack(r *http.Request, stack *portainer.Sta return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} } - _, err = handler.FileService.StoreStackFileFromBytes(string(stack.ID), stack.EntryPoint, []byte(payload.StackFileContent)) + stackFolder := strconv.Itoa(int(stack.ID)) + _, err = handler.FileService.StoreStackFileFromBytes(stackFolder, stack.EntryPoint, []byte(payload.StackFileContent)) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist updated Compose file on disk", err} } @@ -138,7 +140,8 @@ func (handler *Handler) updateSwarmStack(r *http.Request, stack *portainer.Stack stack.Env = payload.Env - _, err = handler.FileService.StoreStackFileFromBytes(string(stack.ID), stack.EntryPoint, []byte(payload.StackFileContent)) + stackFolder := strconv.Itoa(int(stack.ID)) + _, err = handler.FileService.StoreStackFileFromBytes(stackFolder, stack.EntryPoint, []byte(payload.StackFileContent)) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist updated Compose file on disk", err} } From 23b0d6f1dca8c5032770bc44fb01dfd4268baf90 Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Wed, 20 Jun 2018 21:02:53 +0300 Subject: [PATCH 34/37] fix(stack): fix an issue with stack migration --- api/http/handler/stacks/stack_migrate.go | 13 ++++++++++++- app/portainer/rest/stack.js | 2 +- app/portainer/services/api/stackService.js | 4 ++-- app/portainer/views/stacks/edit/stackController.js | 9 +++++++++ 4 files changed, 24 insertions(+), 4 deletions(-) diff --git a/api/http/handler/stacks/stack_migrate.go b/api/http/handler/stacks/stack_migrate.go index 33d410b7b..beb579a34 100644 --- a/api/http/handler/stacks/stack_migrate.go +++ b/api/http/handler/stacks/stack_migrate.go @@ -23,7 +23,7 @@ func (payload *stackMigratePayload) Validate(r *http.Request) error { return nil } -// POST request on /api/stacks/:id/migrate +// POST request on /api/stacks/:id/migrate?endpointId= func (handler *Handler) stackMigrate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { stackID, err := request.RetrieveNumericRouteVariableValue(r, "id") if err != nil { @@ -59,6 +59,17 @@ func (handler *Handler) stackMigrate(w http.ResponseWriter, r *http.Request) *ht } } + // TODO: this is a work-around for stacks created with Portainer version >= 1.17.1 + // The EndpointID property is not available for these stacks, this API endpoint + // can use the optional EndpointID query parameter to associate a valid endpoint identifier to the stack. + endpointID, err := request.RetrieveNumericQueryParameter(r, "endpointId", true) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: endpointId", err} + } + if endpointID != int(stack.EndpointID) { + stack.EndpointID = portainer.EndpointID(endpointID) + } + endpoint, err := handler.EndpointService.Endpoint(stack.EndpointID) if err == portainer.ErrObjectNotFound { return &httperror.HandlerError{http.StatusNotFound, "Unable to find the endpoint associated to the stack inside the database", err} diff --git a/app/portainer/rest/stack.js b/app/portainer/rest/stack.js index d1de2da1e..6d8807791 100644 --- a/app/portainer/rest/stack.js +++ b/app/portainer/rest/stack.js @@ -9,6 +9,6 @@ angular.module('portainer.app') update: { method: 'PUT', params: { id: '@id' }, ignoreLoadingBar: true }, remove: { method: 'DELETE', params: { id: '@id', external: '@external', endpointId: '@endpointId' } }, getStackFile: { method: 'GET', params: { id : '@id', action: 'file' } }, - migrate: { method: 'POST', params: { id : '@id', action: 'migrate' }, ignoreLoadingBar: true } + migrate: { method: 'POST', params: { id : '@id', action: 'migrate', endpointId: '@endpointId' }, ignoreLoadingBar: true } }); }]); diff --git a/app/portainer/services/api/stackService.js b/app/portainer/services/api/stackService.js index 8a70bfc0c..c32f6bec2 100644 --- a/app/portainer/services/api/stackService.js +++ b/app/portainer/services/api/stackService.js @@ -46,7 +46,7 @@ function StackServiceFactory($q, Stack, ResourceControlService, FileUploadServic return; } - return Stack.migrate({ id: stack.Id }, { EndpointID: targetEndpointId, SwarmID: swarm.Id }).$promise; + return Stack.migrate({ id: stack.Id, endpointId: stack.EndpointId }, { EndpointID: targetEndpointId, SwarmID: swarm.Id }).$promise; }) .then(function success(data) { deferred.resolve(); @@ -66,7 +66,7 @@ function StackServiceFactory($q, Stack, ResourceControlService, FileUploadServic EndpointProvider.setEndpointID(targetEndpointId); - Stack.migrate({ id: stack.Id }, { EndpointID: targetEndpointId }).$promise + Stack.migrate({ id: stack.Id, endpointId: stack.EndpointId }, { EndpointID: targetEndpointId }).$promise .then(function success(data) { deferred.resolve(); }) diff --git a/app/portainer/views/stacks/edit/stackController.js b/app/portainer/views/stacks/edit/stackController.js index c7816d41b..57356dbe7 100644 --- a/app/portainer/views/stacks/edit/stackController.js +++ b/app/portainer/views/stacks/edit/stackController.js @@ -54,6 +54,15 @@ function ($q, $scope, $state, $transition$, StackService, NodeService, ServiceSe migrateRequest = StackService.migrateComposeStack; } + // TODO: this is a work-around for stacks created with Portainer version >= 1.17.1 + // The EndpointID property is not available for these stacks, we can pass + // the current endpoint identifier as a part of the migrate request. It will be used if + // the EndpointID property is not defined on the stack. + var endpointId = EndpointProvider.endpointID(); + if (stack.EndpointId === 0) { + stack.EndpointId = endpointId; + } + $scope.state.migrationInProgress = true; migrateRequest(stack, targetEndpointId) .then(function success(data) { From d3a26a4ade7997664fe04e6f1752dd0154925977 Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Thu, 21 Jun 2018 13:59:50 +0300 Subject: [PATCH 35/37] refactor(images): relocate tag/digest replacement --- app/docker/models/image.js | 10 ++++++++++ app/docker/services/imageService.js | 12 ------------ 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/app/docker/models/image.js b/app/docker/models/image.js index 129d1f90b..c964a331b 100644 --- a/app/docker/models/image.js +++ b/app/docker/models/image.js @@ -4,7 +4,17 @@ function ImageViewModel(data) { this.Repository = data.Repository; this.Created = data.Created; this.Checked = false; + this.RepoTags = data.RepoTags; + if (!this.RepoTags && data.RepoDigests) { + this.RepoTags = []; + for (var i = 0; i < data.RepoDigests.length; i++) { + var digest = data.RepoDigests[i]; + var repository = digest.substring(0, digest.indexOf('@')); + this.RepoTags.push(repository + ':'); + } + } + this.VirtualSize = data.VirtualSize; this.ContainerCount = data.ContainerCount; diff --git a/app/docker/services/imageService.js b/app/docker/services/imageService.js index 55e509fcc..112536cca 100644 --- a/app/docker/services/imageService.js +++ b/app/docker/services/imageService.js @@ -20,17 +20,6 @@ angular.module('portainer.docker') return deferred.promise; }; - function replaceEmptyTagsWhenPossible(item) { - if (!item.RepoTags && item.RepoDigests) { - item.RepoTags = []; - for (var iDigest = 0; iDigest < item.RepoDigests.length; iDigest++) { - var digest = item.RepoDigests[iDigest]; - var repository = digest.substring(0, digest.indexOf('@')); - item.RepoTags.push(repository + ':'); - } - } - } - service.images = function(withUsage) { var deferred = $q.defer(); @@ -49,7 +38,6 @@ angular.module('portainer.docker') item.ContainerCount++; } } - replaceEmptyTagsWhenPossible(item); return new ImageViewModel(item); }); From 0a9eab53d077b7b9dc6605d84dbb0491fa78a249 Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Thu, 21 Jun 2018 13:09:57 +0200 Subject: [PATCH 36/37] feat(containers): do not remember selected items (#1988) --- .../containersDatatable.html | 2 +- .../containersDatatableController.js | 31 ------------------- 2 files changed, 1 insertion(+), 32 deletions(-) diff --git a/app/docker/components/datatables/containers-datatable/containersDatatable.html b/app/docker/components/datatables/containers-datatable/containersDatatable.html index 7d63b0831..cfaae7d8c 100644 --- a/app/docker/components/datatables/containers-datatable/containersDatatable.html +++ b/app/docker/components/datatables/containers-datatable/containersDatatable.html @@ -178,7 +178,7 @@
{{ item.IP ? item.IP : '-' }} {{ item.NodeName ? item.NodeName : '-' }} - + {{ p.public }}:{{ p.private }} - diff --git a/app/docker/components/datatables/containers-datatable/containersDatatableController.js b/app/docker/components/datatables/containers-datatable/containersDatatableController.js index 8d59803fe..fe97f901a 100644 --- a/app/docker/components/datatables/containers-datatable/containersDatatableController.js +++ b/app/docker/components/datatables/containers-datatable/containersDatatableController.js @@ -1,7 +1,6 @@ angular.module('portainer.docker') .controller('ContainersDatatableController', ['PaginationService', 'DatatableService', 'EndpointProvider', function (PaginationService, DatatableService, EndpointProvider) { - var ctrl = this; this.state = { @@ -49,7 +48,6 @@ function (PaginationService, DatatableService, EndpointProvider) { this.state.selectedItems.splice(this.state.selectedItems.indexOf(item), 1); this.state.selectedItemCount--; } - DatatableService.setDataTableSelectedItems(this.tableKey + '_' + EndpointProvider.endpointID(), this.state.selectedItems); }; this.selectItem = function(item) { @@ -162,30 +160,6 @@ function (PaginationService, DatatableService, EndpointProvider) { } }; - function selectPreviouslySelectedItem(item, storedSelectedItems) { - var selectedItem = _.find(storedSelectedItems, function(container) { - return item.Id === container.Id; - }); - - if (selectedItem) { - item.Checked = true; - ctrl.state.selectedItemCount++; - ctrl.state.selectedItems.push(item); - } - } - - this.selectItems = function(storedSelectedItems) { - for (var i = 0; i < this.dataset.length; i++) { - var item = this.dataset[i]; - selectPreviouslySelectedItem(item, storedSelectedItems); - } - - if (this.state.selectedItemCount > 0 && this.state.selectedItemCount === this.dataset.length) { - this.state.selectAll = true; - } - this.updateSelectionState(); - }; - this.$onInit = function() { setDefaults(this); this.prepareTableFromDataset(); @@ -196,11 +170,6 @@ function (PaginationService, DatatableService, EndpointProvider) { this.state.orderBy = storedOrder.orderBy; } - var storedSelectedItems = DatatableService.getDataTableSelectedItems(this.tableKey + '_' + EndpointProvider.endpointID()); - if (storedSelectedItems !== null) { - this.selectItems(storedSelectedItems); - } - var storedFilters = DatatableService.getDataTableFilters(this.tableKey); if (storedFilters !== null) { this.updateStoredFilters(storedFilters.state.values); From decb67f4d9296536fe9d49b446e18a12d32d0f86 Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Thu, 21 Jun 2018 14:28:07 +0300 Subject: [PATCH 37/37] chore(version): bump version number --- api/bolt/migrator/migrator.go | 2 +- api/portainer.go | 2 +- api/swagger.yaml | 4 ++-- distribution/portainer.spec | 2 +- package.json | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/api/bolt/migrator/migrator.go b/api/bolt/migrator/migrator.go index 0455aafd1..5e32366ff 100644 --- a/api/bolt/migrator/migrator.go +++ b/api/bolt/migrator/migrator.go @@ -152,7 +152,7 @@ func (m *Migrator) Migrate() error { } } - // Portainer 1.17.1-dev + // Portainer 1.18.0 if m.currentDBVersion < 12 { err := m.updateEndpointsToVersion12() if err != nil { diff --git a/api/portainer.go b/api/portainer.go index 38cea152f..31b9c5203 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -484,7 +484,7 @@ type ( const ( // APIVersion is the version number of the Portainer API. - APIVersion = "1.17.1-dev" + APIVersion = "1.18.0" // DBVersion is the version number of the Portainer database. DBVersion = 12 // DefaultTemplatesURL represents the default URL for the templates definitions. diff --git a/api/swagger.yaml b/api/swagger.yaml index 224b9ecf1..086437dc3 100644 --- a/api/swagger.yaml +++ b/api/swagger.yaml @@ -54,7 +54,7 @@ info: **NOTE**: You can find more information on how to query the Docker API in the [Docker official documentation](https://docs.docker.com/engine/api/v1.30/) as well as in [this Portainer example](https://gist.github.com/deviantony/77026d402366b4b43fa5918d41bc42f8). - version: "1.17.1-dev" + version: "1.18.0" title: "Portainer API" contact: email: "info@portainer.io" @@ -2598,7 +2598,7 @@ definitions: description: "Is analytics enabled" Version: type: "string" - example: "1.17.1-dev" + example: "1.18.0" description: "Portainer API version" PublicSettingsInspectResponse: type: "object" diff --git a/distribution/portainer.spec b/distribution/portainer.spec index c6b13baa4..b99fff674 100644 --- a/distribution/portainer.spec +++ b/distribution/portainer.spec @@ -1,5 +1,5 @@ Name: portainer -Version: 1.17.1-dev +Version: 1.18.0 Release: 0 License: Zlib Summary: A lightweight docker management UI diff --git a/package.json b/package.json index 90a5ba5f7..d536cdccb 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "author": "Portainer.io", "name": "portainer", "homepage": "http://portainer.io", - "version": "1.17.1-dev", + "version": "1.18.0", "repository": { "type": "git", "url": "git@github.com:portainer/portainer.git"