diff --git a/README.md b/README.md index deb337483..b0f5433b2 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,12 @@

- +

-[![Docker Pulls](https://img.shields.io/docker/pulls/portainer/portainer.svg)](https://hub.docker.com/r/portainer/portainer/) -[![Microbadger](https://images.microbadger.com/badges/image/portainer/portainer.svg)](http://microbadger.com/images/portainer/portainer 'Image size') -[![Build Status](https://portainer.visualstudio.com/Portainer%20CI/_apis/build/status/Portainer%20CI?branchName=develop)](https://portainer.visualstudio.com/Portainer%20CI/_build/latest?definitionId=3&branchName=develop) -[![Code Climate](https://codeclimate.com/github/portainer/portainer/badges/gpa.svg)](https://codeclimate.com/github/portainer/portainer) -[![Donate](https://img.shields.io/badge/Donate-PayPal-green.svg)](https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=YHXZJQNJQ36H6) +**Portainer CE** is a lightweight ‘universal’ management GUI that can be used to **easily** manage Docker, Swarm, Kubernetes and ACI environments. It is designed to be as **simple** to deploy as it is to use. -**_Portainer_** is a lightweight management UI which allows you to **easily** manage your different Docker environments (Docker hosts or Swarm clusters). -**_Portainer_** is meant to be as **simple** to deploy as it is to use. It consists of a single container that can run on any Docker engine (can be deployed as Linux container or a Windows native container, supports other platforms too). -**_Portainer_** allows you to manage all your Docker resources (containers, images, volumes, networks and more!) It is compatible with the _standalone Docker_ engine and with _Docker Swarm mode_. +Portainer consists of a single container that can run on any cluster. It can be deployed as a Linux container or a Windows native container. + +**Portainer** allows you to manage all your orchestrator resources (containers, images, volumes, networks and more) through a super-simple graphical interface. ## Demo @@ -18,30 +14,38 @@ You can try out the public demo instance: http://demo.portainer.io/ (login with Please note that the public demo cluster is **reset every 15min**. -Alternatively, you can deploy a copy of the demo stack inside a [play-with-docker (PWD)](https://labs.play-with-docker.com) playground: +## Latest Version -- Browse [PWD/?stack=portainer-demo/play-with-docker/docker-stack.yml](http://play-with-docker.com/?stack=https://raw.githubusercontent.com/portainer/portainer-demo/master/play-with-docker/docker-stack.yml) -- Sign in with your [Docker ID](https://docs.docker.com/docker-id) -- Follow [these](https://github.com/portainer/portainer-demo/blob/master/play-with-docker/docker-stack.yml#L5-L8) steps. +Portainer CE is updated regularly. We aim to do an update release every couple of months. -Unlike the public demo, the playground sessions are deleted after 4 hours. Apart from that, all the settings are the same, including default credentials. +**The latest version of Portainer is 2.6.x** And you can find the release notes [here.](https://www.portainer.io/blog/new-portainer-ce-2.6.0-release) +Portainer is on version 2, the second number denotes the month of release. ## Getting started - [Deploy Portainer](https://documentation.portainer.io/quickstart/) - [Documentation](https://documentation.portainer.io) -- [Building Portainer](https://documentation.portainer.io/contributing/instructions/) +- [Contribute to the project](https://documentation.portainer.io/contributing/instructions/) + +## Features & Functions + +View [this](https://www.portainer.io/products) table to see all of the Portainer CE functionality and compare to Portainer Business. + +- [Portainer CE for Docker / Docker Swarm](https://www.portainer.io/solutions/docker) +- [Portainer CE for Kubernetes](https://www.portainer.io/solutions/kubernetes-ui) +- [Portainer CE for Azure ACI](https://www.portainer.io/solutions/serverless-containers) ## Getting help -For FORMAL Support, please purchase a support subscription from here: https://www.portainer.io/products/portainer-business +Portainer CE is an open source project and is supported by the community. You can buy a supported version of Portainer at portainer.io -For community support: You can find more information about Portainer's community support framework policy here: https://www.portainer.io/products/community-edition/customer-success +Learn more about Portainers community support channels [here.](https://www.portainer.io/help_about) - Issues: https://github.com/portainer/portainer/issues -- FAQ: https://documentation.portainer.io - Slack (chat): https://portainer.io/slack/ +You can join the Portainer Community by visiting community.portainer.io. This will give you advance notice of events, content and other related Portainer content. + ## Reporting bugs and contributing - Want to report a bug or request a feature? Please open [an issue](https://github.com/portainer/portainer/issues/new). diff --git a/api/cli/cli.go b/api/cli/cli.go index ca6534893..3a85b7676 100644 --- a/api/cli/cli.go +++ b/api/cli/cli.go @@ -5,7 +5,7 @@ import ( "log" "time" - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" "os" "path/filepath" diff --git a/api/cmd/portainer/log.go b/api/cmd/portainer/log.go new file mode 100644 index 000000000..5bab3c5de --- /dev/null +++ b/api/cmd/portainer/log.go @@ -0,0 +1,19 @@ +package main + +import ( + "log" + + "github.com/sirupsen/logrus" +) + +func configureLogger() { + logger := logrus.New() // logger is to implicitly substitute stdlib's log + log.SetOutput(logger.Writer()) + + formatter := &logrus.TextFormatter{DisableTimestamp: true, DisableLevelTruncation: true} + logger.SetFormatter(formatter) + logrus.SetFormatter(formatter) + + logger.SetLevel(logrus.DebugLevel) + logrus.SetLevel(logrus.DebugLevel) +} diff --git a/api/cmd/portainer/main.go b/api/cmd/portainer/main.go index 06a004453..b16f92c2f 100644 --- a/api/cmd/portainer/main.go +++ b/api/cmd/portainer/main.go @@ -92,8 +92,8 @@ func initSwarmStackManager(assetsPath string, dataStorePath string, signatureSer return exec.NewSwarmStackManager(assetsPath, dataStorePath, signatureService, fileService, reverseTunnelService) } -func initKubernetesDeployer(dataStore portainer.DataStore, reverseTunnelService portainer.ReverseTunnelService, signatureService portainer.DigitalSignatureService, assetsPath string) portainer.KubernetesDeployer { - return exec.NewKubernetesDeployer(dataStore, reverseTunnelService, signatureService, assetsPath) +func initKubernetesDeployer(kubernetesTokenCacheManager *kubeproxy.TokenCacheManager, kubernetesClientFactory *kubecli.ClientFactory, dataStore portainer.DataStore, reverseTunnelService portainer.ReverseTunnelService, signatureService portainer.DigitalSignatureService, assetsPath string) portainer.KubernetesDeployer { + return exec.NewKubernetesDeployer(kubernetesTokenCacheManager, kubernetesClientFactory, dataStore, reverseTunnelService, signatureService, assetsPath) } func initJWTService(dataStore portainer.DataStore) (portainer.JWTService, error) { @@ -405,7 +405,7 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server { composeStackManager := initComposeStackManager(*flags.Assets, *flags.Data, reverseTunnelService, proxyManager) - kubernetesDeployer := initKubernetesDeployer(dataStore, reverseTunnelService, digitalSignatureService, *flags.Assets) + kubernetesDeployer := initKubernetesDeployer(kubernetesTokenCacheManager, kubernetesClientFactory, dataStore, reverseTunnelService, digitalSignatureService, *flags.Assets) if dataStore.IsNew() { err = updateSettingsFromFlags(dataStore, flags) @@ -506,6 +506,8 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server { func main() { flags := initCLI() + configureLogger() + for { server := buildServer(flags) log.Printf("Starting Portainer %s on %s\n", portainer.APIVersion, *flags.Addr) diff --git a/api/exec/kubernetes_deploy.go b/api/exec/kubernetes_deploy.go index 9d3a07d1d..13ae7faab 100644 --- a/api/exec/kubernetes_deploy.go +++ b/api/exec/kubernetes_deploy.go @@ -5,6 +5,9 @@ import ( "encoding/json" "errors" "fmt" + "github.com/portainer/portainer/api/http/proxy/factory/kubernetes" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/kubernetes/cli" "io/ioutil" "net/http" "net/url" @@ -20,27 +23,64 @@ import ( // KubernetesDeployer represents a service to deploy resources inside a Kubernetes environment. type KubernetesDeployer struct { - binaryPath string - dataStore portainer.DataStore - reverseTunnelService portainer.ReverseTunnelService - signatureService portainer.DigitalSignatureService + binaryPath string + dataStore portainer.DataStore + reverseTunnelService portainer.ReverseTunnelService + signatureService portainer.DigitalSignatureService + kubernetesClientFactory *cli.ClientFactory + kubernetesTokenCacheManager *kubernetes.TokenCacheManager } // NewKubernetesDeployer initializes a new KubernetesDeployer service. -func NewKubernetesDeployer(datastore portainer.DataStore, reverseTunnelService portainer.ReverseTunnelService, signatureService portainer.DigitalSignatureService, binaryPath string) *KubernetesDeployer { +func NewKubernetesDeployer(kubernetesTokenCacheManager *kubernetes.TokenCacheManager, kubernetesClientFactory *cli.ClientFactory, datastore portainer.DataStore, reverseTunnelService portainer.ReverseTunnelService, signatureService portainer.DigitalSignatureService, binaryPath string) *KubernetesDeployer { return &KubernetesDeployer{ - binaryPath: binaryPath, - dataStore: datastore, - reverseTunnelService: reverseTunnelService, - signatureService: signatureService, + binaryPath: binaryPath, + dataStore: datastore, + reverseTunnelService: reverseTunnelService, + signatureService: signatureService, + kubernetesClientFactory: kubernetesClientFactory, + kubernetesTokenCacheManager: kubernetesTokenCacheManager, } } +func (deployer *KubernetesDeployer) getToken(request *http.Request, endpoint *portainer.Endpoint, setLocalAdminToken bool) (string, error) { + tokenData, err := security.RetrieveTokenData(request) + if err != nil { + return "", err + } + + kubecli, err := deployer.kubernetesClientFactory.GetKubeClient(endpoint) + if err != nil { + return "", err + } + + tokenCache := deployer.kubernetesTokenCacheManager.GetOrCreateTokenCache(int(endpoint.ID)) + + tokenManager, err := kubernetes.NewTokenManager(kubecli, deployer.dataStore, tokenCache, setLocalAdminToken) + if err != nil { + return "", err + } + + if tokenData.Role == portainer.AdministratorRole { + return tokenManager.GetAdminServiceAccountToken(), nil + } + + token, err := tokenManager.GetUserServiceAccountToken(int(tokenData.ID), endpoint.ID) + if err != nil { + return "", err + } + + if token == "" { + return "", fmt.Errorf("can not get a valid user service account token") + } + return token, nil +} + // Deploy will deploy a Kubernetes manifest inside a specific namespace in a Kubernetes endpoint. // Otherwise it will use kubectl to deploy the manifest. -func (deployer *KubernetesDeployer) Deploy(endpoint *portainer.Endpoint, stackConfig string, namespace string) (string, error) { +func (deployer *KubernetesDeployer) Deploy(request *http.Request, endpoint *portainer.Endpoint, stackConfig string, namespace string) (string, error) { if endpoint.Type == portainer.KubernetesLocalEnvironment { - token, err := ioutil.ReadFile("/var/run/secrets/kubernetes.io/serviceaccount/token") + token, err := deployer.getToken(request, endpoint, true); if err != nil { return "", err } @@ -53,7 +93,7 @@ func (deployer *KubernetesDeployer) Deploy(endpoint *portainer.Endpoint, stackCo args := make([]string, 0) args = append(args, "--server", endpoint.URL) args = append(args, "--insecure-skip-tls-verify") - args = append(args, "--token", string(token)) + args = append(args, "--token", token) args = append(args, "--namespace", namespace) args = append(args, "apply", "-f", "-") @@ -139,8 +179,14 @@ func (deployer *KubernetesDeployer) Deploy(endpoint *portainer.Endpoint, stackCo return "", err } + token, err := deployer.getToken(request, endpoint, false); + if err != nil { + return "", err + } + req.Header.Set(portainer.PortainerAgentPublicKeyHeader, deployer.signatureService.EncodedPublicKey()) req.Header.Set(portainer.PortainerAgentSignatureHeader, signature) + req.Header.Set(portainer.PortainerAgentKubernetesSATokenHeader, token) resp, err := httpCli.Do(req) if err != nil { diff --git a/api/go.mod b/api/go.mod index 1edf1b571..4c9c57537 100644 --- a/api/go.mod +++ b/api/go.mod @@ -32,6 +32,7 @@ require ( github.com/portainer/libcrypto v0.0.0-20190723020515-23ebe86ab2c2 github.com/portainer/libhttp v0.0.0-20190806161843-ba068f58be33 github.com/robfig/cron/v3 v3.0.1 + github.com/sirupsen/logrus v1.8.1 github.com/stretchr/testify v1.7.0 golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2 golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45 diff --git a/api/go.sum b/api/go.sum index eb9eb8d6b..fcdc0ec1e 100644 --- a/api/go.sum +++ b/api/go.sum @@ -186,7 +186,6 @@ github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQL github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/koding/websocketproxy v0.0.0-20181220232114-7ed82d81a28c h1:N7A4JCA2G+j5fuFxCsJqjFU/sZe0mj8H0sSoSwbaikw= github.com/koding/websocketproxy v0.0.0-20181220232114-7ed82d81a28c/go.mod h1:Nn5wlyECw3iJrzi0AhIWg+AJUb4PlRQVW4/3XHH1LZA= -github.com/konsorten/go-windows-terminal-sequences v1.0.1 h1:mweAR1A6xJ3oS2pRaGiHgQ4OO8tzTaLawm8vnODuwDk= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= @@ -269,8 +268,9 @@ github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzG github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= -github.com/sirupsen/logrus v1.4.1 h1:GL2rEmy6nsikmW0r8opw9JIRScdMF5hA8cOYLH7In1k= github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= +github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE= +github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/spf13/afero v1.2.2/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk= github.com/spf13/pflag v0.0.0-20170130214245-9ff6c6923cff/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= diff --git a/api/http/handler/endpoints/endpoint_association_delete.go b/api/http/handler/endpoints/endpoint_association_delete.go new file mode 100644 index 000000000..ad294c883 --- /dev/null +++ b/api/http/handler/endpoints/endpoint_association_delete.go @@ -0,0 +1,56 @@ +package endpoints + +import ( + "errors" + "net/http" + + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" + "github.com/portainer/libhttp/response" + portainer "github.com/portainer/portainer/api" + bolterrors "github.com/portainer/portainer/api/bolt/errors" +) + +// @id EndpointAssociationDelete +// @summary De-association an edge endpoint +// @description De-association an edge endpoint. +// @description **Access policy**: administrator +// @security jwt +// @tags endpoints +// @produce json +// @param id path int true "Endpoint identifier" +// @success 200 {object} portainer.Endpoint "Success" +// @failure 400 "Invalid request" +// @failure 404 "Endpoint not found" +// @failure 500 "Server error" +// @router /api/endpoints/:id/association [put] +func (handler *Handler) endpointAssociationDelete(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.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID)) + if err == bolterrors.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} + } + + if endpoint.Type != portainer.EdgeAgentOnKubernetesEnvironment && endpoint.Type != portainer.EdgeAgentOnDockerEnvironment { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid endpoint type", errors.New("Invalid endpoint type")} + } + + endpoint.EdgeID = "" + endpoint.Snapshots = []portainer.DockerSnapshot{} + endpoint.Kubernetes.Snapshots = []portainer.KubernetesSnapshot{} + + err = handler.DataStore.Endpoint().UpdateEndpoint(portainer.EndpointID(endpointID), endpoint) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Failed persisting endpoint in database", err} + } + + handler.ReverseTunnelService.SetTunnelStatusToIdle(endpoint.ID) + + return response.JSON(w, endpoint) +} diff --git a/api/http/handler/endpoints/endpoint_dockerhub_status.go b/api/http/handler/endpoints/endpoint_dockerhub_status.go index 92cfbef9c..7a8cc44a3 100644 --- a/api/http/handler/endpoints/endpoint_dockerhub_status.go +++ b/api/http/handler/endpoints/endpoint_dockerhub_status.go @@ -132,8 +132,18 @@ func getDockerHubLimits(httpClient *client.HTTPClient, token string) (*dockerhub return nil, errors.New("failed fetching dockerhub limits") } + // An error with rateLimit-Limit or RateLimit-Remaining is likely for dockerhub pro accounts where there is no rate limit. + // In that specific case the headers will not be present. Don't bubble up the error as its normal + // See: https://docs.docker.com/docker-hub/download-rate-limit/ rateLimit, err := parseRateLimitHeader(resp.Header, "RateLimit-Limit") + if err != nil { + return nil, nil + } + rateLimitRemaining, err := parseRateLimitHeader(resp.Header, "RateLimit-Remaining") + if err != nil { + return nil, nil + } return &dockerhubStatusResponse{ Limit: rateLimit, diff --git a/api/http/handler/endpoints/handler.go b/api/http/handler/endpoints/handler.go index bee2eb1d6..25f8d18a0 100644 --- a/api/http/handler/endpoints/handler.go +++ b/api/http/handler/endpoints/handler.go @@ -45,6 +45,8 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler { bouncer.AdminAccess(httperror.LoggerHandler(h.endpointCreate))).Methods(http.MethodPost) h.Handle("/endpoints/{id}/settings", bouncer.AdminAccess(httperror.LoggerHandler(h.endpointSettingsUpdate))).Methods(http.MethodPut) + h.Handle("/endpoints/{id}/association", + bouncer.AdminAccess(httperror.LoggerHandler(h.endpointAssociationDelete))).Methods(http.MethodDelete) h.Handle("/endpoints/snapshot", bouncer.AdminAccess(httperror.LoggerHandler(h.endpointSnapshots))).Methods(http.MethodPost) h.Handle("/endpoints", diff --git a/api/http/handler/stacks/create_kubernetes_stack.go b/api/http/handler/stacks/create_kubernetes_stack.go index 918d1047d..4de14d3a3 100644 --- a/api/http/handler/stacks/create_kubernetes_stack.go +++ b/api/http/handler/stacks/create_kubernetes_stack.go @@ -95,7 +95,7 @@ func (handler *Handler) createKubernetesStackFromFileContent(w http.ResponseWrit doCleanUp := true defer handler.cleanUp(stack, &doCleanUp) - output, err := handler.deployKubernetesStack(endpoint, payload.StackFileContent, payload.ComposeFormat, payload.Namespace) + output, err := handler.deployKubernetesStack(r, endpoint, payload.StackFileContent, payload.ComposeFormat, payload.Namespace) if err != nil { return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to deploy Kubernetes stack", Err: err} } @@ -139,7 +139,7 @@ func (handler *Handler) createKubernetesStackFromGitRepository(w http.ResponseWr return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Failed to process manifest from Git repository", Err: err} } - output, err := handler.deployKubernetesStack(endpoint, stackFileContent, payload.ComposeFormat, payload.Namespace) + output, err := handler.deployKubernetesStack(r, endpoint, stackFileContent, payload.ComposeFormat, payload.Namespace) if err != nil { return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to deploy Kubernetes stack", Err: err} } @@ -155,7 +155,7 @@ func (handler *Handler) createKubernetesStackFromGitRepository(w http.ResponseWr return response.JSON(w, resp) } -func (handler *Handler) deployKubernetesStack(endpoint *portainer.Endpoint, stackConfig string, composeFormat bool, namespace string) (string, error) { +func (handler *Handler) deployKubernetesStack(request *http.Request, endpoint *portainer.Endpoint, stackConfig string, composeFormat bool, namespace string) (string, error) { handler.stackCreationMutex.Lock() defer handler.stackCreationMutex.Unlock() @@ -167,7 +167,7 @@ func (handler *Handler) deployKubernetesStack(endpoint *portainer.Endpoint, stac stackConfig = string(convertedConfig) } - return handler.KubernetesDeployer.Deploy(endpoint, stackConfig, namespace) + return handler.KubernetesDeployer.Deploy(request, endpoint, stackConfig, namespace) } diff --git a/api/http/handler/websocket/handler.go b/api/http/handler/websocket/handler.go index 05cd88cfc..517df5756 100644 --- a/api/http/handler/websocket/handler.go +++ b/api/http/handler/websocket/handler.go @@ -5,6 +5,7 @@ import ( "github.com/gorilla/websocket" httperror "github.com/portainer/libhttp/error" portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/proxy/factory/kubernetes" "github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/kubernetes/cli" ) @@ -12,20 +13,22 @@ import ( // Handler is the HTTP handler used to handle websocket operations. type Handler struct { *mux.Router - DataStore portainer.DataStore - SignatureService portainer.DigitalSignatureService - ReverseTunnelService portainer.ReverseTunnelService - KubernetesClientFactory *cli.ClientFactory - requestBouncer *security.RequestBouncer - connectionUpgrader websocket.Upgrader + DataStore portainer.DataStore + SignatureService portainer.DigitalSignatureService + ReverseTunnelService portainer.ReverseTunnelService + KubernetesClientFactory *cli.ClientFactory + requestBouncer *security.RequestBouncer + connectionUpgrader websocket.Upgrader + kubernetesTokenCacheManager *kubernetes.TokenCacheManager } // NewHandler creates a handler to manage websocket operations. -func NewHandler(bouncer *security.RequestBouncer) *Handler { +func NewHandler(kubernetesTokenCacheManager *kubernetes.TokenCacheManager, bouncer *security.RequestBouncer) *Handler { h := &Handler{ Router: mux.NewRouter(), connectionUpgrader: websocket.Upgrader{}, requestBouncer: bouncer, + kubernetesTokenCacheManager: kubernetesTokenCacheManager, } h.PathPrefix("/websocket/exec").Handler( bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.websocketExec))) diff --git a/api/http/handler/websocket/pod.go b/api/http/handler/websocket/pod.go index 3ae12750a..f1509cc67 100644 --- a/api/http/handler/websocket/pod.go +++ b/api/http/handler/websocket/pod.go @@ -1,6 +1,8 @@ package websocket import ( + "fmt" + "github.com/portainer/portainer/api/http/security" "io" "log" "net/http" @@ -11,6 +13,7 @@ import ( "github.com/portainer/libhttp/request" portainer "github.com/portainer/portainer/api" bolterrors "github.com/portainer/portainer/api/bolt/errors" + "github.com/portainer/portainer/api/http/proxy/factory/kubernetes" ) // @summary Execute a websocket on pod @@ -70,8 +73,14 @@ func (handler *Handler) websocketPodExec(w http.ResponseWriter, r *http.Request) return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err} } + token, useAdminToken, err := handler.getToken(r, endpoint, false) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to get user service account token", err} + } + params := &webSocketRequestParams{ endpoint: endpoint, + token: token, } r.Header.Del("Origin") @@ -112,7 +121,7 @@ func (handler *Handler) websocketPodExec(w http.ResponseWriter, r *http.Request) return &httperror.HandlerError{http.StatusInternalServerError, "Unable to create Kubernetes client", err} } - err = cli.StartExecProcess(namespace, podName, containerName, commandArray, stdinReader, stdoutWriter) + err = cli.StartExecProcess(token, useAdminToken, namespace, podName, containerName, commandArray, stdinReader, stdoutWriter) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to start exec process inside container", err} } @@ -124,3 +133,37 @@ func (handler *Handler) websocketPodExec(w http.ResponseWriter, r *http.Request) return nil } + +func (handler *Handler) getToken(request *http.Request, endpoint *portainer.Endpoint, setLocalAdminToken bool) (string, bool, error) { + tokenData, err := security.RetrieveTokenData(request) + if err != nil { + return "", false, err + } + + kubecli, err := handler.KubernetesClientFactory.GetKubeClient(endpoint) + if err != nil { + return "", false, err + } + + tokenCache := handler.kubernetesTokenCacheManager.GetOrCreateTokenCache(int(endpoint.ID)) + + tokenManager, err := kubernetes.NewTokenManager(kubecli, handler.DataStore, tokenCache, setLocalAdminToken) + if err != nil { + return "", false, err + } + + if tokenData.Role == portainer.AdministratorRole { + return tokenManager.GetAdminServiceAccountToken(), true, nil + } + + token, err := tokenManager.GetUserServiceAccountToken(int(tokenData.ID), endpoint.ID) + if err != nil { + return "", false, err + } + + if token == "" { + return "", false, fmt.Errorf("can not get a valid user service account token") + } + + return token, false, nil +} diff --git a/api/http/handler/websocket/proxy.go b/api/http/handler/websocket/proxy.go index 984240256..14072d315 100644 --- a/api/http/handler/websocket/proxy.go +++ b/api/http/handler/websocket/proxy.go @@ -24,6 +24,7 @@ func (handler *Handler) proxyEdgeAgentWebsocketRequest(w http.ResponseWriter, r proxy.Director = func(incoming *http.Request, out http.Header) { out.Set(portainer.PortainerAgentTargetHeader, params.nodeName) + out.Set(portainer.PortainerAgentKubernetesSATokenHeader, params.token) } handler.ReverseTunnelService.SetTunnelStatusToActive(params.endpoint.ID) @@ -64,6 +65,7 @@ func (handler *Handler) proxyAgentWebsocketRequest(w http.ResponseWriter, r *htt out.Set(portainer.PortainerAgentPublicKeyHeader, handler.SignatureService.EncodedPublicKey()) out.Set(portainer.PortainerAgentSignatureHeader, signature) out.Set(portainer.PortainerAgentTargetHeader, params.nodeName) + out.Set(portainer.PortainerAgentKubernetesSATokenHeader, params.token) } proxy.ServeHTTP(w, r) diff --git a/api/http/handler/websocket/types.go b/api/http/handler/websocket/types.go index abb86c7db..b321ea075 100644 --- a/api/http/handler/websocket/types.go +++ b/api/http/handler/websocket/types.go @@ -8,4 +8,5 @@ type webSocketRequestParams struct { ID string nodeName string endpoint *portainer.Endpoint + token string } diff --git a/api/http/proxy/factory/kubernetes/token.go b/api/http/proxy/factory/kubernetes/token.go index 909df1811..cc1e0e4f7 100644 --- a/api/http/proxy/factory/kubernetes/token.go +++ b/api/http/proxy/factory/kubernetes/token.go @@ -1,10 +1,8 @@ package kubernetes import ( - "io/ioutil" - "sync" - portainer "github.com/portainer/portainer/api" + "io/ioutil" ) const defaultServiceAccountTokenFile = "/var/run/secrets/kubernetes.io/serviceaccount/token" @@ -13,7 +11,6 @@ type tokenManager struct { tokenCache *tokenCache kubecli portainer.KubeClient dataStore portainer.DataStore - mutex sync.Mutex adminToken string } @@ -25,7 +22,6 @@ func NewTokenManager(kubecli portainer.KubeClient, dataStore portainer.DataStore tokenCache: cache, kubecli: kubecli, dataStore: dataStore, - mutex: sync.Mutex{}, adminToken: "", } @@ -41,13 +37,13 @@ func NewTokenManager(kubecli portainer.KubeClient, dataStore portainer.DataStore return tokenManager, nil } -func (manager *tokenManager) getAdminServiceAccountToken() string { +func (manager *tokenManager) GetAdminServiceAccountToken() string { return manager.adminToken } -func (manager *tokenManager) getUserServiceAccountToken(userID int, endpointID portainer.EndpointID) (string, error) { - manager.mutex.Lock() - defer manager.mutex.Unlock() +func (manager *tokenManager) GetUserServiceAccountToken(userID int, endpointID portainer.EndpointID) (string, error) { + manager.tokenCache.mutex.Lock() + defer manager.tokenCache.mutex.Unlock() token, ok := manager.tokenCache.getToken(userID) if !ok { diff --git a/api/http/proxy/factory/kubernetes/token_cache.go b/api/http/proxy/factory/kubernetes/token_cache.go index 552e6b3a1..316b3a3e9 100644 --- a/api/http/proxy/factory/kubernetes/token_cache.go +++ b/api/http/proxy/factory/kubernetes/token_cache.go @@ -2,6 +2,7 @@ package kubernetes import ( "strconv" + "sync" "github.com/orcaman/concurrent-map" ) @@ -14,6 +15,7 @@ type ( tokenCache struct { userTokenCache cmap.ConcurrentMap + mutex sync.Mutex } ) @@ -35,6 +37,18 @@ func (manager *TokenCacheManager) CreateTokenCache(endpointID int) *tokenCache { return tokenCache } +// GetOrCreateTokenCache will get the tokenCache from the manager map of caches if it exists, +// otherwise it will create a new tokenCache object, associate it to the manager map of caches +// and return a pointer to that tokenCache instance. +func (manager *TokenCacheManager) GetOrCreateTokenCache(endpointID int) *tokenCache { + key := strconv.Itoa(endpointID) + if epCache, ok := manager.tokenCaches.Get(key); ok { + return epCache.(*tokenCache) + } + + return manager.CreateTokenCache(endpointID) +} + // RemoveUserFromCache will ensure that the specific userID is removed from all registered caches. func (manager *TokenCacheManager) RemoveUserFromCache(userID int) { for cache := range manager.tokenCaches.IterBuffered() { @@ -45,6 +59,7 @@ func (manager *TokenCacheManager) RemoveUserFromCache(userID int) { func newTokenCache() *tokenCache { return &tokenCache{ userTokenCache: cmap.New(), + mutex: sync.Mutex{}, } } diff --git a/api/http/proxy/factory/kubernetes/transport.go b/api/http/proxy/factory/kubernetes/transport.go index d87dea133..ae7036698 100644 --- a/api/http/proxy/factory/kubernetes/transport.go +++ b/api/http/proxy/factory/kubernetes/transport.go @@ -110,9 +110,9 @@ func (transport *baseTransport) getRoundTripToken(request *http.Request, tokenMa var token string if tokenData.Role == portainer.AdministratorRole { - token = tokenManager.getAdminServiceAccountToken() + token = tokenManager.GetAdminServiceAccountToken() } else { - token, err = tokenManager.getUserServiceAccountToken(int(tokenData.ID), transport.endpoint.ID) + token, err = tokenManager.GetUserServiceAccountToken(int(tokenData.ID), transport.endpoint.ID) if err != nil { log.Printf("Failed retrieving service account token: %v", err) return "", err diff --git a/api/http/server.go b/api/http/server.go index 52b109718..6c38f2efd 100644 --- a/api/http/server.go +++ b/api/http/server.go @@ -208,7 +208,7 @@ func (server *Server) Start() error { userHandler.DataStore = server.DataStore userHandler.CryptoService = server.CryptoService - var websocketHandler = websocket.NewHandler(requestBouncer) + var websocketHandler = websocket.NewHandler(server.KubernetesTokenCacheManager, requestBouncer) websocketHandler.DataStore = server.DataStore websocketHandler.SignatureService = server.SignatureService websocketHandler.ReverseTunnelService = server.ReverseTunnelService diff --git a/api/kubernetes/cli/exec.go b/api/kubernetes/cli/exec.go index 1716b10e6..55cc38bc9 100644 --- a/api/kubernetes/cli/exec.go +++ b/api/kubernetes/cli/exec.go @@ -14,13 +14,18 @@ import ( // StartExecProcess will start an exec process inside a container located inside a pod inside a specific namespace // using the specified command. The stdin parameter will be bound to the stdin process and the stdout process will write // to the stdout parameter. -// This function only works against a local endpoint using an in-cluster config. -func (kcl *KubeClient) StartExecProcess(namespace, podName, containerName string, command []string, stdin io.Reader, stdout io.Writer) error { +// This function only works against a local endpoint using an in-cluster config with the user's SA token. +func (kcl *KubeClient) StartExecProcess(token string, useAdminToken bool, namespace, podName, containerName string, command []string, stdin io.Reader, stdout io.Writer) error { config, err := rest.InClusterConfig() if err != nil { return err } + if !useAdminToken { + config.BearerToken = token + config.BearerTokenFile = "" + } + req := kcl.cli.CoreV1().RESTClient(). Post(). Resource("pods"). diff --git a/api/portainer.go b/api/portainer.go index b987bb691..7518fd57d 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -2,6 +2,7 @@ package portainer import ( "io" + "net/http" "time" gittypes "github.com/portainer/portainer/api/git/types" @@ -1188,7 +1189,7 @@ type ( KubeClient interface { SetupUserServiceAccount(userID int, teamIDs []int, restrictDefaultNamespace bool) error GetServiceAccountBearerToken(userID int) (string, error) - StartExecProcess(namespace, podName, containerName string, command []string, stdin io.Reader, stdout io.Writer) error + StartExecProcess(token string, useAdminToken bool, namespace, podName, containerName string, command []string, stdin io.Reader, stdout io.Writer) error NamespaceAccessPoliciesDeleteNamespace(namespace string) error GetNamespaceAccessPolicies() (map[string]K8sNamespaceAccessPolicy, error) UpdateNamespaceAccessPolicies(accessPolicies map[string]K8sNamespaceAccessPolicy) error @@ -1199,7 +1200,7 @@ type ( // KubernetesDeployer represents a service to deploy a manifest inside a Kubernetes endpoint KubernetesDeployer interface { - Deploy(endpoint *Endpoint, data string, namespace string) (string, error) + Deploy(request *http.Request, endpoint *Endpoint, data string, namespace string) (string, error) ConvertCompose(data string) ([]byte, error) } diff --git a/app/assets/images/portainer-github-banner.png b/app/assets/images/portainer-github-banner.png new file mode 100644 index 000000000..08776d3e3 Binary files /dev/null and b/app/assets/images/portainer-github-banner.png differ diff --git a/app/docker/components/imageRegistry/por-image-registry-rate-limits.controller.js b/app/docker/components/imageRegistry/por-image-registry-rate-limits.controller.js index 0c23eba56..809f1ba52 100644 --- a/app/docker/components/imageRegistry/por-image-registry-rate-limits.controller.js +++ b/app/docker/components/imageRegistry/por-image-registry-rate-limits.controller.js @@ -32,7 +32,7 @@ export default class porImageRegistryContainerController { try { this.pullRateLimits = await this.DockerHubService.checkRateLimits(this.endpoint, this.registryId || 0); - this.setValidity(this.pullRateLimits.remaining >= 0); + this.setValidity(!this.pullRateLimits.limit || (this.pullRateLimits.limit && this.pullRateLimits.remaining >= 0)); } catch (e) { // eslint-disable-next-line no-console console.error('Failed loading DockerHub pull rate limits', e); diff --git a/app/kubernetes/components/resource-reservation/resourceReservation.html b/app/kubernetes/components/resource-reservation/resourceReservation.html index e03e62ace..d838145ae 100644 --- a/app/kubernetes/components/resource-reservation/resourceReservation.html +++ b/app/kubernetes/components/resource-reservation/resourceReservation.html @@ -10,22 +10,42 @@
-
+
+ +
+ + {{ $ctrl.memoryUsage }} / {{ $ctrl.memoryLimit }} MB - {{ $ctrl.memoryUsagePercent }}%
-
+
+ +
+ + {{ $ctrl.cpuUsage | kubernetesApplicationCPUValue }} / {{ $ctrl.cpuLimit }} - {{ $ctrl.cpuUsagePercent }}%
diff --git a/app/kubernetes/components/resource-reservation/resourceReservation.js b/app/kubernetes/components/resource-reservation/resourceReservation.js index 617f70079..d104fda73 100644 --- a/app/kubernetes/components/resource-reservation/resourceReservation.js +++ b/app/kubernetes/components/resource-reservation/resourceReservation.js @@ -3,9 +3,12 @@ angular.module('portainer.kubernetes').component('kubernetesResourceReservation' controller: 'KubernetesResourceReservationController', bindings: { description: '@', - cpu: '<', + cpuReservation: '<', + cpuUsage: '<', cpuLimit: '<', - memory: '<', + memoryReservation: '<', + memoryUsage: '<', memoryLimit: '<', + displayUsage: '<', }, }); diff --git a/app/kubernetes/components/resource-reservation/resourceReservationController.js b/app/kubernetes/components/resource-reservation/resourceReservationController.js index f55a014a1..1c25cb23f 100644 --- a/app/kubernetes/components/resource-reservation/resourceReservationController.js +++ b/app/kubernetes/components/resource-reservation/resourceReservationController.js @@ -3,10 +3,15 @@ import angular from 'angular'; class KubernetesResourceReservationController { usageValues() { if (this.cpuLimit) { - this.cpuUsage = Math.round((this.cpu / this.cpuLimit) * 100); + this.cpuReservationPercent = Math.round((this.cpuReservation / this.cpuLimit) * 100); } if (this.memoryLimit) { - this.memoryUsage = Math.round((this.memory / this.memoryLimit) * 100); + this.memoryReservationPercent = Math.round((this.memoryReservation / this.memoryLimit) * 100); + } + + if (this.displayUsage && this.cpuLimit && this.memoryLimit) { + this.cpuUsagePercent = Math.round((this.cpuUsage / this.cpuLimit) * 100); + this.memoryUsagePercent = Math.round((this.memoryUsage / this.memoryLimit) * 100); } } diff --git a/app/kubernetes/metrics/metrics.js b/app/kubernetes/metrics/metrics.js index 391a26509..c374bd2e0 100644 --- a/app/kubernetes/metrics/metrics.js +++ b/app/kubernetes/metrics/metrics.js @@ -9,8 +9,12 @@ class KubernetesMetricsService { this.KubernetesMetrics = KubernetesMetrics; this.capabilitiesAsync = this.capabilitiesAsync.bind(this); + this.getPodAsync = this.getPodAsync.bind(this); this.getNodeAsync = this.getNodeAsync.bind(this); + + this.getPodsAsync = this.getPodsAsync.bind(this); + this.getNodesAsync = this.getNodesAsync.bind(this); } /** @@ -68,6 +72,42 @@ class KubernetesMetricsService { getPod(namespace, podName) { return this.$async(this.getPodAsync, namespace, podName); } + + /** + * Stats of Nodes in cluster + * + * @param {string} endpointID + */ + async getNodesAsync(endpointID) { + try { + const data = await this.KubernetesMetrics().getNodes({ endpointId: endpointID }).$promise; + return data; + } catch (err) { + throw new PortainerError('Unable to retrieve nodes stats', err); + } + } + + getNodes(endpointID) { + return this.$async(this.getNodesAsync, endpointID); + } + + /** + * Stats of Pods in a namespace + * + * @param {string} namespace + */ + async getPodsAsync(namespace) { + try { + const data = await this.KubernetesMetrics(namespace).getPods().$promise; + return data; + } catch (err) { + throw new PortainerError('Unable to retrieve pod stats', err); + } + } + + getPods(namespace) { + return this.$async(this.getPodsAsync, namespace); + } } export default KubernetesMetricsService; diff --git a/app/kubernetes/metrics/rest.js b/app/kubernetes/metrics/rest.js index 92fa6b95b..1dc97c212 100644 --- a/app/kubernetes/metrics/rest.js +++ b/app/kubernetes/metrics/rest.js @@ -24,6 +24,14 @@ angular.module('portainer.kubernetes').factory('KubernetesMetrics', [ method: 'GET', url: `${url}/nodes/:id`, }, + getPods: { + method: 'GET', + url: `${url}/namespaces/:namespace/pods`, + }, + getNodes: { + method: 'GET', + url: `${url}/nodes`, + }, } ); }; diff --git a/app/kubernetes/views/cluster/cluster.html b/app/kubernetes/views/cluster/cluster.html index d1c9f08cb..f64e517b5 100644 --- a/app/kubernetes/views/cluster/cluster.html +++ b/app/kubernetes/views/cluster/cluster.html @@ -12,11 +12,14 @@
diff --git a/app/kubernetes/views/cluster/cluster.js b/app/kubernetes/views/cluster/cluster.js index 688cf877e..708076037 100644 --- a/app/kubernetes/views/cluster/cluster.js +++ b/app/kubernetes/views/cluster/cluster.js @@ -2,4 +2,7 @@ angular.module('portainer.kubernetes').component('kubernetesClusterView', { templateUrl: './cluster.html', controller: 'KubernetesClusterController', controllerAs: 'ctrl', + bindings: { + endpoint: '<', + }, }); diff --git a/app/kubernetes/views/cluster/clusterController.js b/app/kubernetes/views/cluster/clusterController.js index 6824e0738..3b78e230d 100644 --- a/app/kubernetes/views/cluster/clusterController.js +++ b/app/kubernetes/views/cluster/clusterController.js @@ -13,10 +13,10 @@ class KubernetesClusterController { Notifications, LocalStorage, KubernetesNodeService, + KubernetesMetricsService, KubernetesApplicationService, KubernetesComponentStatusService, - KubernetesEndpointService, - EndpointProvider + KubernetesEndpointService ) { this.$async = $async; this.$state = $state; @@ -24,10 +24,10 @@ class KubernetesClusterController { this.Notifications = Notifications; this.LocalStorage = LocalStorage; this.KubernetesNodeService = KubernetesNodeService; + this.KubernetesMetricsService = KubernetesMetricsService; this.KubernetesApplicationService = KubernetesApplicationService; this.KubernetesComponentStatusService = KubernetesComponentStatusService; this.KubernetesEndpointService = KubernetesEndpointService; - this.EndpointProvider = EndpointProvider; this.onInit = this.onInit.bind(this); this.getNodes = this.getNodes.bind(this); @@ -106,6 +106,10 @@ class KubernetesClusterController { new KubernetesResourceReservation() ); this.resourceReservation.Memory = KubernetesResourceReservationHelper.megaBytesValue(this.resourceReservation.Memory); + + if (this.isAdmin) { + await this.getResourceUsage(this.endpoint.Id); + } } catch (err) { this.Notifications.error('Failure', 'Unable to retrieve applications', err); } finally { @@ -117,14 +121,31 @@ class KubernetesClusterController { return this.$async(this.getApplicationsAsync); } + async getResourceUsage(endpointId) { + try { + const nodeMetrics = await this.KubernetesMetricsService.getNodes(endpointId); + const resourceUsageList = nodeMetrics.items.map((i) => i.usage); + const clusterResourceUsage = resourceUsageList.reduce((total, u) => { + total.CPU += KubernetesResourceReservationHelper.parseCPU(u.cpu); + total.Memory += KubernetesResourceReservationHelper.megaBytesValue(u.memory); + return total; + }, new KubernetesResourceReservation()); + this.resourceUsage = clusterResourceUsage; + } catch (err) { + this.Notifications.error('Failure', 'Unable to retrieve cluster resource usage', err); + } + } + async onInit() { this.state = { applicationsLoading: true, viewReady: false, hasUnhealthyComponentStatus: false, + useServerMetrics: false, }; this.isAdmin = this.Authentication.isAdmin(); + this.state.useServerMetrics = this.endpoint.Kubernetes.Configuration.UseServerMetrics; await this.getNodes(); if (this.isAdmin) { @@ -134,7 +155,6 @@ class KubernetesClusterController { } this.state.viewReady = true; - this.state.useServerMetrics = this.EndpointProvider.currentEndpoint().Kubernetes.Configuration.UseServerMetrics; } $onInit() { diff --git a/app/kubernetes/views/cluster/node/node.html b/app/kubernetes/views/cluster/node/node.html index c4c9229c1..50105fe5b 100644 --- a/app/kubernetes/views/cluster/node/node.html +++ b/app/kubernetes/views/cluster/node/node.html @@ -78,11 +78,14 @@
diff --git a/app/kubernetes/views/cluster/node/node.js b/app/kubernetes/views/cluster/node/node.js index 38fb14ede..bf29d91c1 100644 --- a/app/kubernetes/views/cluster/node/node.js +++ b/app/kubernetes/views/cluster/node/node.js @@ -3,6 +3,7 @@ angular.module('portainer.kubernetes').component('kubernetesNodeView', { controller: 'KubernetesNodeController', controllerAs: 'ctrl', bindings: { + endpoint: '<', $transition$: '<', }, }); diff --git a/app/kubernetes/views/cluster/node/nodeController.js b/app/kubernetes/views/cluster/node/nodeController.js index fe55409e1..8b4369415 100644 --- a/app/kubernetes/views/cluster/node/nodeController.js +++ b/app/kubernetes/views/cluster/node/nodeController.js @@ -21,7 +21,9 @@ class KubernetesNodeController { KubernetesEventService, KubernetesPodService, KubernetesApplicationService, - KubernetesEndpointService + KubernetesEndpointService, + KubernetesMetricsService, + Authentication ) { this.$async = $async; this.$state = $state; @@ -33,6 +35,8 @@ class KubernetesNodeController { this.KubernetesPodService = KubernetesPodService; this.KubernetesApplicationService = KubernetesApplicationService; this.KubernetesEndpointService = KubernetesEndpointService; + this.KubernetesMetricsService = KubernetesMetricsService; + this.Authentication = Authentication; this.onInit = this.onInit.bind(this); this.getNodesAsync = this.getNodesAsync.bind(this); @@ -42,6 +46,7 @@ class KubernetesNodeController { this.getEndpointsAsync = this.getEndpointsAsync.bind(this); this.updateNodeAsync = this.updateNodeAsync.bind(this); this.drainNodeAsync = this.drainNodeAsync.bind(this); + this.getNodeUsageAsync = this.getNodeUsageAsync.bind(this); } selectTab(index) { @@ -327,6 +332,22 @@ class KubernetesNodeController { return this.$async(this.getNodesAsync); } + async getNodeUsageAsync() { + try { + const nodeName = this.$transition$.params().name; + const node = await this.KubernetesMetricsService.getNode(nodeName); + this.resourceUsage = new KubernetesResourceReservation(); + this.resourceUsage.CPU = KubernetesResourceReservationHelper.parseCPU(node.usage.cpu); + this.resourceUsage.Memory = KubernetesResourceReservationHelper.megaBytesValue(node.usage.memory); + } catch (err) { + this.Notifications.error('Failure', 'Unable to retrieve node resource usage', err); + } + } + + getNodeUsage() { + return this.$async(this.getNodeUsageAsync); + } + hasEventWarnings() { return this.state.eventWarningCount; } @@ -375,6 +396,10 @@ class KubernetesNodeController { this.resourceReservation.Memory = KubernetesResourceReservationHelper.megaBytesValue(this.resourceReservation.Memory); this.memoryLimit = KubernetesResourceReservationHelper.megaBytesValue(this.node.Memory); this.state.isContainPortainer = _.find(this.applications, { ApplicationName: 'portainer' }); + + if (this.state.isAdmin) { + await this.getNodeUsage(); + } } catch (err) { this.Notifications.error('Failure', err, 'Unable to retrieve applications'); } finally { @@ -388,6 +413,7 @@ class KubernetesNodeController { async onInit() { this.state = { + isAdmin: this.Authentication.isAdmin(), activeTab: 0, currentName: this.$state.$current.name, dataLoading: true, @@ -402,10 +428,13 @@ class KubernetesNodeController { hasDuplicateLabelKeys: false, isDrainOperation: false, isContainPortainer: false, + useServerMetrics: false, }; this.availabilities = KubernetesNodeAvailabilities; + this.state.useServerMetrics = this.endpoint.Kubernetes.Configuration.UseServerMetrics; + this.state.activeTab = this.LocalStorage.getActiveTab('node'); await this.getNodes(); diff --git a/app/kubernetes/views/resource-pools/edit/resourcePool.html b/app/kubernetes/views/resource-pools/edit/resourcePool.html index 2a7ece853..de5e75f60 100644 --- a/app/kubernetes/views/resource-pools/edit/resourcePool.html +++ b/app/kubernetes/views/resource-pools/edit/resourcePool.html @@ -40,10 +40,13 @@ diff --git a/app/kubernetes/views/resource-pools/edit/resourcePoolController.js b/app/kubernetes/views/resource-pools/edit/resourcePoolController.js index 31c5ff6f3..6e2da1339 100644 --- a/app/kubernetes/views/resource-pools/edit/resourcePoolController.js +++ b/app/kubernetes/views/resource-pools/edit/resourcePoolController.js @@ -3,6 +3,7 @@ import _ from 'lodash-es'; import filesizeParser from 'filesize-parser'; import { KubernetesResourceQuotaDefaults } from 'Kubernetes/models/resource-quota/models'; import KubernetesResourceReservationHelper from 'Kubernetes/helpers/resourceReservationHelper'; +import { KubernetesResourceReservation } from 'Kubernetes/models/resource-reservation/models'; import KubernetesEventHelper from 'Kubernetes/helpers/eventHelper'; import { KubernetesResourcePoolFormValues, @@ -27,6 +28,7 @@ class KubernetesResourcePoolController { EndpointService, ModalService, KubernetesNodeService, + KubernetesMetricsService, KubernetesResourceQuotaService, KubernetesResourcePoolService, KubernetesEventService, @@ -45,6 +47,7 @@ class KubernetesResourcePoolController { EndpointService, ModalService, KubernetesNodeService, + KubernetesMetricsService, KubernetesResourceQuotaService, KubernetesResourcePoolService, KubernetesEventService, @@ -240,6 +243,8 @@ class KubernetesResourcePoolController { app.Memory = resourceReservation.Memory; return app; }); + + await this.getResourceUsage(this.pool.Namespace.Name); } catch (err) { this.Notifications.error('Failure', err, 'Unable to retrieve applications.'); } finally { @@ -300,11 +305,26 @@ class KubernetesResourcePoolController { } /* #endregion */ + async getResourceUsage(namespace) { + try { + const namespaceMetrics = await this.KubernetesMetricsService.getPods(namespace); + // extract resource usage of all containers within each pod of the namespace + const containerResourceUsageList = namespaceMetrics.items.flatMap((i) => i.containers.map((c) => c.usage)); + const namespaceResourceUsage = containerResourceUsageList.reduce((total, u) => { + total.CPU += KubernetesResourceReservationHelper.parseCPU(u.cpu); + total.Memory += KubernetesResourceReservationHelper.megaBytesValue(u.memory); + return total; + }, new KubernetesResourceReservation()); + this.state.resourceUsage = namespaceResourceUsage; + } catch (err) { + this.Notifications.error('Failure', 'Unable to retrieve namespace resource usage', err); + } + } + /* #region ON INIT */ $onInit() { return this.$async(async () => { try { - const endpoint = this.endpoint; this.isAdmin = this.Authentication.isAdmin(); this.state = { @@ -312,9 +332,8 @@ class KubernetesResourcePoolController { sliderMaxMemory: 0, sliderMaxCpu: 0, cpuUsage: 0, - cpuUsed: 0, memoryUsage: 0, - memoryUsed: 0, + resourceReservation: { CPU: 0, Memory: 0 }, activeTab: 0, currentName: this.$state.$current.name, showEditorTab: false, @@ -323,7 +342,8 @@ class KubernetesResourcePoolController { ingressesLoading: true, viewReady: false, eventWarningCount: 0, - canUseIngress: endpoint.Kubernetes.Configuration.IngressClasses.length, + canUseIngress: this.endpoint.Kubernetes.Configuration.IngressClasses.length, + useServerMetrics: this.endpoint.Kubernetes.Configuration.UseServerMetrics, duplicates: { ingressHosts: new KubernetesFormValidationReferences(), }, @@ -352,8 +372,8 @@ class KubernetesResourcePoolController { this.formValues = KubernetesResourceQuotaConverter.quotaToResourcePoolFormValues(quota); this.formValues.EndpointId = this.endpoint.Id; - this.state.cpuUsed = quota.CpuLimitUsed; - this.state.memoryUsed = KubernetesResourceReservationHelper.megaBytesValue(quota.MemoryLimitUsed); + this.state.resourceReservation.CPU = quota.CpuLimitUsed; + this.state.resourceReservation.Memory = KubernetesResourceReservationHelper.megaBytesValue(quota.MemoryLimitUsed); } this.isEditable = !this.KubernetesNamespaceHelper.isSystemNamespace(this.pool.Namespace.Name); @@ -366,7 +386,7 @@ class KubernetesResourcePoolController { if (this.state.canUseIngress) { await this.getIngresses(); - const ingressClasses = endpoint.Kubernetes.Configuration.IngressClasses; + const ingressClasses = this.endpoint.Kubernetes.Configuration.IngressClasses; this.formValues.IngressClasses = KubernetesIngressConverter.ingressClassesToFormValues(ingressClasses, this.ingresses); _.forEach(this.formValues.IngressClasses, (ic) => { if (ic.Hosts.length === 0) { diff --git a/app/portainer/rest/endpoint.js b/app/portainer/rest/endpoint.js index 52bc57f92..9e25024c2 100644 --- a/app/portainer/rest/endpoint.js +++ b/app/portainer/rest/endpoint.js @@ -16,6 +16,7 @@ angular.module('portainer.app').factory('Endpoints', [ }, get: { method: 'GET', params: { id: '@id' } }, update: { method: 'PUT', params: { id: '@id' } }, + deassociate: { method: 'DELETE', params: { id: '@id', action: 'association' } }, updateAccess: { method: 'PUT', params: { id: '@id', action: 'access' } }, remove: { method: 'DELETE', params: { id: '@id' } }, snapshots: { method: 'POST', params: { action: 'snapshot' } }, diff --git a/app/portainer/services/api/endpointService.js b/app/portainer/services/api/endpointService.js index e272b8a08..33dd04e29 100644 --- a/app/portainer/services/api/endpointService.js +++ b/app/portainer/services/api/endpointService.js @@ -41,6 +41,10 @@ angular.module('portainer.app').factory('EndpointService', [ return Endpoints.updateAccess({ id: id }, { UserAccessPolicies: userAccessPolicies, TeamAccessPolicies: teamAccessPolicies }).$promise; }; + service.deassociateEndpoint = function (endpointID) { + return Endpoints.deassociate({ id: endpointID }).$promise; + }; + service.updateEndpoint = function (id, payload) { var deferred = $q.defer(); FileUploadService.uploadTLSFilesForEndpoint(id, payload.TLSCACert, payload.TLSCert, payload.TLSKey) diff --git a/app/portainer/services/modalService.js b/app/portainer/services/modalService.js index 4baee3d82..7e03ba7bb 100644 --- a/app/portainer/services/modalService.js +++ b/app/portainer/services/modalService.js @@ -152,6 +152,24 @@ angular.module('portainer.app').factory('ModalService', [ }); }; + service.confirmDeassociate = function (callback) { + const message = + '

De-associating this Edge endpoint will mark it as non associated and will clear the registered Edge ID.

' + + '

Any agent started with the Edge key associated to this endpoint will be able to re-associate with this endpoint.

' + + '

You can re-use the Edge ID and Edge key that you used to deploy the existing Edge agent to associate a new Edge device to this endpoint.

'; + service.confirm({ + title: 'About de-associating', + message: $sanitize(message), + buttons: { + confirm: { + label: 'De-associate', + className: 'btn-primary', + }, + }, + callback: callback, + }); + }; + service.confirmUpdate = function (message, callback) { message = $sanitize(message); service.confirm({ diff --git a/app/portainer/views/endpoints/edit/endpoint.html b/app/portainer/views/endpoints/edit/endpoint.html index 4b16b5078..defa4f9c4 100644 --- a/app/portainer/views/endpoints/edit/endpoint.html +++ b/app/portainer/views/endpoints/edit/endpoint.html @@ -22,6 +22,11 @@

Edge identifier: {{ endpoint.EdgeID }}

+

+ +

diff --git a/app/portainer/views/endpoints/edit/endpointController.js b/app/portainer/views/endpoints/edit/endpointController.js index aec783f80..9952de857 100644 --- a/app/portainer/views/endpoints/edit/endpointController.js +++ b/app/portainer/views/endpoints/edit/endpointController.js @@ -19,7 +19,8 @@ angular EndpointProvider, Notifications, Authentication, - SettingsService + SettingsService, + ModalService ) { $scope.state = { uploadInProgress: false, @@ -113,6 +114,29 @@ angular } } + $scope.onDeassociateEndpoint = async function () { + ModalService.confirmDeassociate((confirmed) => { + if (confirmed) { + deassociateEndpoint(); + } + }); + }; + + async function deassociateEndpoint() { + var endpoint = $scope.endpoint; + + try { + $scope.state.actionInProgress = true; + await EndpointService.deassociateEndpoint(endpoint.Id); + Notifications.success('Endpoint de-associated', $scope.endpoint.Name); + $state.reload(); + } catch (err) { + Notifications.error('Failure', err, 'Unable to de-associate endpoint'); + } finally { + $scope.state.actionInProgress = false; + } + } + $scope.updateEndpoint = function () { var endpoint = $scope.endpoint; var securityData = $scope.formValues.SecurityFormData; diff --git a/app/portainer/views/stacks/edit/stack.html b/app/portainer/views/stacks/edit/stack.html index 3cf057c10..57fdc69f6 100644 --- a/app/portainer/views/stacks/edit/stack.html +++ b/app/portainer/views/stacks/edit/stack.html @@ -152,7 +152,7 @@ -
+