diff --git a/api/http/handler/handler.go b/api/http/handler/handler.go index f4d1eceea..1e65fae85 100644 --- a/api/http/handler/handler.go +++ b/api/http/handler/handler.go @@ -16,6 +16,7 @@ import ( "github.com/portainer/portainer/api/http/handler/endpointproxy" "github.com/portainer/portainer/api/http/handler/endpoints" "github.com/portainer/portainer/api/http/handler/file" + "github.com/portainer/portainer/api/http/handler/kubernetes" "github.com/portainer/portainer/api/http/handler/motd" "github.com/portainer/portainer/api/http/handler/registries" "github.com/portainer/portainer/api/http/handler/resourcecontrols" @@ -47,6 +48,7 @@ type Handler struct { EndpointHandler *endpoints.Handler EndpointProxyHandler *endpointproxy.Handler FileHandler *file.Handler + KubernetesHandler *kubernetes.Handler MOTDHandler *motd.Handler RegistryHandler *registries.Handler ResourceControlHandler *resourcecontrols.Handler @@ -100,6 +102,8 @@ type Handler struct { // @tag.description Manage Docker environments // @tag.name endpoint_groups // @tag.description Manage endpoint groups +// @tag.name kubernetes +// @tag.description Manage Kubernetes cluster // @tag.name motd // @tag.description Fetch the message of the day // @tag.name registries @@ -156,6 +160,8 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { http.StripPrefix("/api", h.EdgeTemplatesHandler).ServeHTTP(w, r) case strings.HasPrefix(r.URL.Path, "/api/endpoint_groups"): http.StripPrefix("/api", h.EndpointGroupHandler).ServeHTTP(w, r) + case strings.HasPrefix(r.URL.Path, "/api/kubernetes"): + http.StripPrefix("/api", h.KubernetesHandler).ServeHTTP(w, r) case strings.HasPrefix(r.URL.Path, "/api/endpoints"): switch { case strings.Contains(r.URL.Path, "/docker/"): diff --git a/api/http/handler/kubernetes/handler.go b/api/http/handler/kubernetes/handler.go new file mode 100644 index 000000000..686e79883 --- /dev/null +++ b/api/http/handler/kubernetes/handler.go @@ -0,0 +1,28 @@ +package kubernetes + +import ( + "net/http" + + "github.com/gorilla/mux" + httperror "github.com/portainer/libhttp/error" + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/kubernetes/cli" +) + +// Handler is the HTTP handler which will natively deal with to external endpoints. +type Handler struct { + *mux.Router + DataStore portainer.DataStore + KubernetesClientFactory *cli.ClientFactory +} + +// NewHandler creates a handler to process pre-proxied requests to external APIs. +func NewHandler(bouncer *security.RequestBouncer) *Handler { + h := &Handler{ + Router: mux.NewRouter(), + } + h.PathPrefix("/kubernetes/{id}/config").Handler( + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.getKubernetesConfig))).Methods(http.MethodGet) + return h +} diff --git a/api/http/handler/kubernetes/kubernetes_config.go b/api/http/handler/kubernetes/kubernetes_config.go new file mode 100644 index 000000000..c9c4c21e4 --- /dev/null +++ b/api/http/handler/kubernetes/kubernetes_config.go @@ -0,0 +1,132 @@ +package kubernetes + +import ( + "errors" + "fmt" + "strings" + + 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" + httperrors "github.com/portainer/portainer/api/http/errors" + "github.com/portainer/portainer/api/http/security" + kcli "github.com/portainer/portainer/api/kubernetes/cli" + + "net/http" +) + +// @id GetKubernetesConfig +// @summary Generates kubeconfig file enabling client communication with k8s api server +// @description Generates kubeconfig file enabling client communication with k8s api server +// @description **Access policy**: authorized +// @tags kubernetes +// @security jwt +// @accept json +// @produce json +// @param id path int true "Endpoint identifier" +// @success 200 "Success" +// @failure 400 "Invalid request" +// @failure 401 "Unauthorized" +// @failure 403 "Permission denied" +// @failure 404 "Endpoint or ServiceAccount not found" +// @failure 500 "Server error" +// @router /kubernetes/{id}/config [get] +func (handler *Handler) getKubernetesConfig(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + if r.TLS == nil { + return &httperror.HandlerError{ + StatusCode: http.StatusInternalServerError, + Message: "Kubernetes config generation only supported on portainer instances running with TLS", + Err: errors.New("missing request TLS config"), + } + } + + 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} + } + + bearerToken, err := extractBearerToken(r) + if err != nil { + return &httperror.HandlerError{http.StatusUnauthorized, "Unauthorized", err} + } + + tokenData, err := security.RetrieveTokenData(r) + if err != nil { + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err} + } + + cli, err := handler.KubernetesClientFactory.GetKubeClient(endpoint) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to create Kubernetes client", err} + } + + apiServerURL := getProxyUrl(r, endpointID) + + config, err := cli.GetKubeConfig(r.Context(), apiServerURL, bearerToken, tokenData) + if err != nil { + return &httperror.HandlerError{http.StatusNotFound, "Unable to generate Kubeconfig", err} + } + + contentAcceptHeader := r.Header.Get("Accept") + if contentAcceptHeader == "text/yaml" { + yaml, err := kcli.GenerateYAML(config) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Failed to generate Kubeconfig", err} + } + w.Header().Set("Content-Disposition", `attachment; filename=config.yaml`) + return YAML(w, yaml) + } + + w.Header().Set("Content-Disposition", `attachment; filename="config.json"`) + return response.JSON(w, config) +} + +// extractBearerToken extracts user's portainer bearer token from request auth header +func extractBearerToken(r *http.Request) (string, error) { + token := "" + tokens := r.Header["Authorization"] + if len(tokens) >= 1 { + token = tokens[0] + token = strings.TrimPrefix(token, "Bearer ") + } + if token == "" { + return "", httperrors.ErrUnauthorized + } + return token, nil +} + +// getProxyUrl generates portainer proxy url which acts as proxy to k8s api server +func getProxyUrl(r *http.Request, endpointID int) string { + return fmt.Sprintf("https://%s/api/endpoints/%d/kubernetes", r.Host, endpointID) +} + +// YAML writes yaml response as string to writer. Returns a pointer to a HandlerError if encoding fails. +// This could be moved to a more useful place; but that place is most likely not in this project. +// It should actually go in https://github.com/portainer/libhttp - since that is from where we use response.JSON. +// We use `data interface{}` as parameter - since im trying to keep it as close to (or the same as) response.JSON method signature: +// https://github.com/portainer/libhttp/blob/d20481a3da823c619887c440a22fdf4fa8f318f2/response/response.go#L13 +func YAML(rw http.ResponseWriter, data interface{}) *httperror.HandlerError { + rw.Header().Set("Content-Type", "text/yaml") + + strData, ok := data.(string) + if !ok { + return &httperror.HandlerError{ + StatusCode: http.StatusInternalServerError, + Message: "Unable to write YAML response", + Err: errors.New("failed to convert input to string"), + } + } + + fmt.Fprint(rw, strData) + + return nil +} diff --git a/api/http/handler/websocket/handler.go b/api/http/handler/websocket/handler.go index 517df5756..477f72500 100644 --- a/api/http/handler/websocket/handler.go +++ b/api/http/handler/websocket/handler.go @@ -36,5 +36,7 @@ func NewHandler(kubernetesTokenCacheManager *kubernetes.TokenCacheManager, bounc bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.websocketAttach))) h.PathPrefix("/websocket/pod").Handler( bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.websocketPodExec))) + h.PathPrefix("/websocket/kubernetes-shell").Handler( + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.websocketShellPodExec))) return h } diff --git a/api/http/handler/websocket/pod.go b/api/http/handler/websocket/pod.go index f1509cc67..8fd943890 100644 --- a/api/http/handler/websocket/pod.go +++ b/api/http/handler/websocket/pod.go @@ -2,12 +2,13 @@ package websocket import ( "fmt" - "github.com/portainer/portainer/api/http/security" "io" "log" "net/http" "strings" + "github.com/portainer/portainer/api/http/security" + "github.com/gorilla/websocket" httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" @@ -73,14 +74,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) + serviceAccountToken, isAdminToken, 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, + token: serviceAccountToken, } r.Header.Del("Origin") @@ -99,6 +100,28 @@ func (handler *Handler) websocketPodExec(w http.ResponseWriter, r *http.Request) return nil } + cli, err := handler.KubernetesClientFactory.GetKubeClient(endpoint) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to create Kubernetes client", err} + } + + handlerErr := handler.hijackPodExecStartOperation(w, r, cli, serviceAccountToken, isAdminToken, endpoint, namespace, podName, containerName, command) + if handlerErr != nil { + return handlerErr + } + + return nil +} + +func (handler *Handler) hijackPodExecStartOperation( + w http.ResponseWriter, + r *http.Request, + cli portainer.KubeClient, + serviceAccountToken string, + isAdminToken bool, + endpoint *portainer.Endpoint, + namespace, podName, containerName, command string, +) *httperror.HandlerError { commandArray := strings.Split(command, " ") websocketConn, err := handler.connectionUpgrader.Upgrade(w, r, nil) @@ -116,12 +139,7 @@ func (handler *Handler) websocketPodExec(w http.ResponseWriter, r *http.Request) go streamFromWebsocketToWriter(websocketConn, stdinWriter, errorChan) go streamFromReaderToWebsocket(websocketConn, stdoutReader, errorChan) - cli, err := handler.KubernetesClientFactory.GetKubeClient(endpoint) - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to create Kubernetes client", err} - } - - err = cli.StartExecProcess(token, useAdminToken, namespace, podName, containerName, commandArray, stdinReader, stdoutWriter) + err = cli.StartExecProcess(serviceAccountToken, isAdminToken, namespace, podName, containerName, commandArray, stdinReader, stdoutWriter) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to start exec process inside container", err} } diff --git a/api/http/handler/websocket/shell_pod.go b/api/http/handler/websocket/shell_pod.go new file mode 100644 index 000000000..c47b311bc --- /dev/null +++ b/api/http/handler/websocket/shell_pod.go @@ -0,0 +1,111 @@ +package websocket + +import ( + "net/http" + + httperror "github.com/portainer/libhttp/error" + "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/security" +) + +// websocketShellPodExec handles GET requests on /websocket/pod?token=&endpointId= +// The request will be upgraded to the websocket protocol. +// Authentication and access is controlled via the mandatory token query parameter. +// The request will proxy input from the client to the pod via long-lived websocket connection. +// The following query parameters are mandatory: +// * token: JWT token used for authentication against this endpoint +// * endpointId: endpoint ID of the endpoint where the resource is located +func (handler *Handler) websocketShellPodExec(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + endpointID, err := request.RetrieveNumericQueryParameter(r, "endpointId", false) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: endpointId", err} + } + + endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID)) + if err == bolterrors.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} + } + + tokenData, err := security.RetrieveTokenData(r) + if err != nil { + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err} + } + + cli, err := handler.KubernetesClientFactory.GetKubeClient(endpoint) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to create Kubernetes client", err} + } + + serviceAccount, err := cli.GetServiceAccount(tokenData) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find serviceaccount associated with user", err} + } + + shellPod, err := cli.CreateUserShellPod(r.Context(), serviceAccount.Name) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to create user shell", err} + } + + // Modifying request params mid-flight before forewarding to K8s API server (websocket) + q := r.URL.Query() + + q.Add("namespace", shellPod.Namespace) + q.Add("podName", shellPod.PodName) + q.Add("containerName", shellPod.ContainerName) + q.Add("command", shellPod.ShellExecCommand) + + r.URL.RawQuery = q.Encode() + + // Modify url path mid-flight before forewarding to k8s API server (websocket) + r.URL.Path = "/websocket/pod" + + /* + Note: The following websocket proxying logic is duplicated from `api/http/handler/websocket/pod.go` + */ + params := &webSocketRequestParams{ + endpoint: endpoint, + } + + r.Header.Del("Origin") + + if endpoint.Type == portainer.AgentOnKubernetesEnvironment { + err := handler.proxyAgentWebsocketRequest(w, r, params) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to proxy websocket request to agent", err} + } + return nil + } else if endpoint.Type == portainer.EdgeAgentOnKubernetesEnvironment { + err := handler.proxyEdgeAgentWebsocketRequest(w, r, params) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to proxy websocket request to Edge agent", err} + } + return nil + } + + serviceAccountToken, isAdminToken, err := handler.getToken(r, endpoint, false) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to get user service account token", err} + } + + handlerErr := handler.hijackPodExecStartOperation( + w, + r, + cli, + serviceAccountToken, + isAdminToken, + endpoint, + shellPod.Namespace, + shellPod.PodName, + shellPod.ContainerName, + shellPod.ShellExecCommand, + ) + if handlerErr != nil { + return handlerErr + } + + return nil +} diff --git a/api/http/server.go b/api/http/server.go index cec683560..9d0f81225 100644 --- a/api/http/server.go +++ b/api/http/server.go @@ -25,6 +25,7 @@ import ( "github.com/portainer/portainer/api/http/handler/endpointproxy" "github.com/portainer/portainer/api/http/handler/endpoints" "github.com/portainer/portainer/api/http/handler/file" + kube "github.com/portainer/portainer/api/http/handler/kubernetes" "github.com/portainer/portainer/api/http/handler/motd" "github.com/portainer/portainer/api/http/handler/registries" "github.com/portainer/portainer/api/http/handler/resourcecontrols" @@ -152,6 +153,10 @@ func (server *Server) Start() error { var fileHandler = file.NewHandler(filepath.Join(server.AssetsPath, "public")) + var kubernetesHandler = kube.NewHandler(requestBouncer) + kubernetesHandler.DataStore = server.DataStore + kubernetesHandler.KubernetesClientFactory = server.KubernetesClientFactory + var motdHandler = motd.NewHandler(requestBouncer) var registryHandler = registries.NewHandler(requestBouncer) @@ -226,6 +231,7 @@ func (server *Server) Start() error { EndpointEdgeHandler: endpointEdgeHandler, EndpointProxyHandler: endpointProxyHandler, FileHandler: fileHandler, + KubernetesHandler: kubernetesHandler, MOTDHandler: motdHandler, RegistryHandler: registryHandler, ResourceControlHandler: resourceControlHandler, diff --git a/api/kubernetes/cli/kubeconfig.go b/api/kubernetes/cli/kubeconfig.go new file mode 100644 index 000000000..2f9c37fa0 --- /dev/null +++ b/api/kubernetes/cli/kubeconfig.go @@ -0,0 +1,66 @@ +package cli + +import ( + "context" + "fmt" + + portainer "github.com/portainer/portainer/api" + clientV1 "k8s.io/client-go/tools/clientcmd/api/v1" +) + +// GetKubeConfig returns kubeconfig for the current user based on: +// - portainer server url +// - portainer user bearer token +// - portainer token data - which maps to k8s service account +func (kcl *KubeClient) GetKubeConfig(ctx context.Context, apiServerURL string, bearerToken string, tokenData *portainer.TokenData) (*clientV1.Config, error) { + serviceAccount, err := kcl.GetServiceAccount(tokenData) + if err != nil { + errText := fmt.Sprintf("unable to find serviceaccount associated with user; username=%s", tokenData.Username) + return nil, fmt.Errorf("%s; err=%w", errText, err) + } + + kubeconfig := generateKubeconfig(apiServerURL, bearerToken, serviceAccount.Name) + + return kubeconfig, nil +} + +// generateKubeconfig will generate and return kubeconfig resource - usable by `kubectl` cli +// which will allow the client to connect directly to k8s server endpoint via portainer (proxy) +func generateKubeconfig(apiServerURL, bearerToken, serviceAccountName string) *clientV1.Config { + const ( + KubeConfigPortainerContext = "portainer-ctx" + KubeConfigPortainerCluster = "portainer-cluster" + ) + + return &clientV1.Config{ + APIVersion: "v1", + Kind: "Config", + CurrentContext: KubeConfigPortainerContext, + Contexts: []clientV1.NamedContext{ + { + Name: KubeConfigPortainerContext, + Context: clientV1.Context{ + AuthInfo: serviceAccountName, + Cluster: KubeConfigPortainerCluster, + }, + }, + }, + Clusters: []clientV1.NamedCluster{ + { + Name: KubeConfigPortainerCluster, + Cluster: clientV1.Cluster{ + Server: apiServerURL, + InsecureSkipTLSVerify: true, + }, + }, + }, + AuthInfos: []clientV1.NamedAuthInfo{ + { + Name: serviceAccountName, + AuthInfo: clientV1.AuthInfo{ + Token: bearerToken, + }, + }, + }, + } +} diff --git a/api/kubernetes/cli/kubeconfig_test.go b/api/kubernetes/cli/kubeconfig_test.go new file mode 100644 index 000000000..ed7649d8e --- /dev/null +++ b/api/kubernetes/cli/kubeconfig_test.go @@ -0,0 +1,150 @@ +package cli + +import ( + "context" + "errors" + "testing" + + portainer "github.com/portainer/portainer/api" + v1 "k8s.io/api/core/v1" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + kfake "k8s.io/client-go/kubernetes/fake" +) + +func Test_GetKubeConfig(t *testing.T) { + + t.Run("returns error if SA non-existent", func(t *testing.T) { + k := &KubeClient{ + cli: kfake.NewSimpleClientset(), + instanceID: "test", + } + + tokenData := &portainer.TokenData{ + ID: 1, + Role: portainer.AdministratorRole, + Username: portainerClusterAdminServiceAccountName, + } + + _, err := k.GetKubeConfig(context.Background(), "localhost", "abc", tokenData) + + if err == nil { + t.Error("GetKubeConfig should fail as service account does not exist") + } + if k8sErr := errors.Unwrap(err); !k8serrors.IsNotFound(k8sErr) { + t.Error("GetKubeConfig should fail with service account not found k8s error") + } + }) + + t.Run("successfully obtains kubeconfig for cluster admin", func(t *testing.T) { + k := &KubeClient{ + cli: kfake.NewSimpleClientset(), + instanceID: "test", + } + + tokenData := &portainer.TokenData{ + Role: portainer.AdministratorRole, + Username: portainerClusterAdminServiceAccountName, + } + serviceAccount := &v1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{Name: tokenData.Username}, + } + + k.cli.CoreV1().ServiceAccounts(portainerNamespace).Create(serviceAccount) + defer k.cli.CoreV1().ServiceAccounts(portainerNamespace).Delete(serviceAccount.Name, nil) + + _, err := k.GetKubeConfig(context.Background(), "localhost", "abc", tokenData) + + if err != nil { + t.Errorf("GetKubeConfig should succeed; err=%s", err) + } + }) + + t.Run("successfully obtains kubeconfig for standard user", func(t *testing.T) { + k := &KubeClient{ + cli: kfake.NewSimpleClientset(), + instanceID: "test", + } + + tokenData := &portainer.TokenData{ + ID: 1, + Role: portainer.StandardUserRole, + } + nonAdminUserName := userServiceAccountName(int(tokenData.ID), k.instanceID) + serviceAccount := &v1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{Name: nonAdminUserName}, + } + + k.cli.CoreV1().ServiceAccounts(portainerNamespace).Create(serviceAccount) + defer k.cli.CoreV1().ServiceAccounts(portainerNamespace).Delete(serviceAccount.Name, nil) + + _, err := k.GetKubeConfig(context.Background(), "localhost", "abc", tokenData) + + if err != nil { + t.Errorf("GetKubeConfig should succeed; err=%s", err) + } + }) +} + +func Test_generateKubeconfig(t *testing.T) { + apiServerURL, bearerToken, serviceAccountName := "localhost", "test-token", "test-user" + + t.Run("generates Config resource kind", func(t *testing.T) { + config := generateKubeconfig(apiServerURL, bearerToken, serviceAccountName) + want := "Config" + if config.Kind != want { + t.Errorf("generateKubeconfig resource kind should be %s", want) + } + }) + + t.Run("generates v1 version", func(t *testing.T) { + config := generateKubeconfig(apiServerURL, bearerToken, serviceAccountName) + want := "v1" + if config.APIVersion != want { + t.Errorf("generateKubeconfig api version should be %s", want) + } + }) + + t.Run("generates single entry context cluster and authinfo", func(t *testing.T) { + config := generateKubeconfig(apiServerURL, bearerToken, serviceAccountName) + if len(config.Contexts) != 1 { + t.Error("generateKubeconfig should generate single context configuration") + } + if len(config.Clusters) != 1 { + t.Error("generateKubeconfig should generate single cluster configuration") + } + if len(config.AuthInfos) != 1 { + t.Error("generateKubeconfig should generate single user configuration") + } + }) + + t.Run("sets default context appropriately", func(t *testing.T) { + config := generateKubeconfig(apiServerURL, bearerToken, serviceAccountName) + want := "portainer-ctx" + if config.CurrentContext != want { + t.Errorf("generateKubeconfig set cluster to be %s", want) + } + }) + + t.Run("generates cluster with InsecureSkipTLSVerify to be set to true", func(t *testing.T) { + config := generateKubeconfig(apiServerURL, bearerToken, serviceAccountName) + if config.Clusters[0].Cluster.InsecureSkipTLSVerify != true { + t.Error("generateKubeconfig default cluster InsecureSkipTLSVerify should be true") + } + }) + + t.Run("should contain passed in value", func(t *testing.T) { + config := generateKubeconfig(apiServerURL, bearerToken, serviceAccountName) + if config.Clusters[0].Cluster.Server != apiServerURL { + t.Errorf("generateKubeconfig default cluster server url should be %s", apiServerURL) + } + + if config.AuthInfos[0].Name != serviceAccountName { + t.Errorf("generateKubeconfig default authinfo name should be %s", serviceAccountName) + } + + if config.AuthInfos[0].AuthInfo.Token != bearerToken { + t.Errorf("generateKubeconfig default authinfo user token should be %s", bearerToken) + } + }) +} diff --git a/api/kubernetes/cli/naming.go b/api/kubernetes/cli/naming.go index 9297ff6be..5bff9b076 100644 --- a/api/kubernetes/cli/naming.go +++ b/api/kubernetes/cli/naming.go @@ -1,16 +1,20 @@ package cli -import "fmt" +import ( + "fmt" +) const ( - defaultNamespace = "default" - portainerNamespace = "portainer" - portainerUserCRName = "portainer-cr-user" - portainerUserCRBName = "portainer-crb-user" - portainerUserServiceAccountPrefix = "portainer-sa-user" - portainerRBPrefix = "portainer-rb" - portainerConfigMapName = "portainer-config" - portainerConfigMapAccessPoliciesKey = "NamespaceAccessPolicies" + defaultNamespace = "default" + portainerNamespace = "portainer" + portainerUserCRName = "portainer-cr-user" + portainerUserCRBName = "portainer-crb-user" + portainerClusterAdminServiceAccountName = "portainer-sa-clusteradmin" + portainerUserServiceAccountPrefix = "portainer-sa-user" + portainerRBPrefix = "portainer-rb" + portainerConfigMapName = "portainer-config" + portainerConfigMapAccessPoliciesKey = "NamespaceAccessPolicies" + portainerShellPodPrefix = "portainer-pod-kubectl-shell" ) func userServiceAccountName(userID int, instanceID string) string { @@ -24,3 +28,7 @@ func userServiceAccountTokenSecretName(serviceAccountName string, instanceID str func namespaceClusterRoleBindingName(namespace string, instanceID string) string { return fmt.Sprintf("%s-%s-%s", portainerRBPrefix, instanceID, namespace) } + +func userShellPodPrefix(serviceAccountName string) string { + return fmt.Sprintf("%s-%s-", portainerShellPodPrefix, serviceAccountName) +} diff --git a/api/kubernetes/cli/pod.go b/api/kubernetes/cli/pod.go new file mode 100644 index 000000000..3db620d55 --- /dev/null +++ b/api/kubernetes/cli/pod.go @@ -0,0 +1,120 @@ +package cli + +import ( + "context" + "fmt" + "log" + "time" + + "github.com/pkg/errors" + portainer "github.com/portainer/portainer/api" + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +const shellPodImage = "portainer/kubectl-shell" + +// CreateUserShellPod will create a kubectl based shell for the specified user by mounting their respective service account. +// The lifecycle of the pod is managed in this function; this entails management of the following pod operations: +// - The shell pod will be scoped to specified service accounts access permissions +// - The shell pod will be automatically removed if it's not ready after specified period of time +// - The shell pod will be automatically removed after a specified max life (prevent zombie pods) +// - The shell pod will be automatically removed if request is cancelled (or client closes websocket connection) +func (kcl *KubeClient) CreateUserShellPod(ctx context.Context, serviceAccountName string) (*portainer.KubernetesShellPod, error) { + // Schedule the pod for automatic removal + maxPodKeepAlive := 1 * time.Hour + maxPodKeepAliveSecondsStr := fmt.Sprintf("%d", int(maxPodKeepAlive.Seconds())) + + podPrefix := userShellPodPrefix(serviceAccountName) + + podSpec := &v1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: podPrefix, + Namespace: portainerNamespace, + Annotations: map[string]string{ + "kubernetes.io/pod.type": "kubectl-shell", + }, + }, + Spec: v1.PodSpec{ + TerminationGracePeriodSeconds: new(int64), + ServiceAccountName: serviceAccountName, + Containers: []v1.Container{ + { + Name: "kubectl-shell-container", + Image: shellPodImage, + Command: []string{"sleep"}, + // Specify sleep time to prevent zombie pods in case portainer process is terminated + Args: []string{maxPodKeepAliveSecondsStr}, + ImagePullPolicy: v1.PullIfNotPresent, + }, + }, + RestartPolicy: v1.RestartPolicyNever, + }, + } + + shellPod, err := kcl.cli.CoreV1().Pods(portainerNamespace).Create(podSpec) + if err != nil { + return nil, errors.Wrap(err, "error creating shell pod") + } + + // Wait for pod to reach ready state + timeoutCtx, cancelFunc := context.WithTimeout(ctx, 20*time.Second) + defer cancelFunc() + err = kcl.waitForPodStatus(timeoutCtx, v1.PodRunning, shellPod) + if err != nil { + kcl.cli.CoreV1().Pods(portainerNamespace).Delete(shellPod.Name, nil) + return nil, errors.Wrap(err, "aborting pod creation; error waiting for shell pod ready status") + } + + if len(shellPod.Spec.Containers) != 1 { + kcl.cli.CoreV1().Pods(portainerNamespace).Delete(shellPod.Name, nil) + return nil, fmt.Errorf("incorrect shell pod state, expecting single container to be present") + } + + podData := &portainer.KubernetesShellPod{ + Namespace: shellPod.Namespace, + PodName: shellPod.Name, + ContainerName: shellPod.Spec.Containers[0].Name, + ShellExecCommand: "env COLUMNS=200 /bin/bash", // env COLUMNS dictates minimum width of the shell + } + + // Handle pod lifecycle/cleanup - terminate pod after maxPodKeepAlive or upon request (long-lived) cancellation + go func() { + select { + case <-time.After(maxPodKeepAlive): + log.Println("[DEBUG] [internal,kubernetes/pod] [message: pod removal schedule duration exceeded]") + kcl.cli.CoreV1().Pods(portainerNamespace).Delete(shellPod.Name, nil) + case <-ctx.Done(): + err := ctx.Err() + log.Printf("[DEBUG] [internal,kubernetes/pod] [message: context error: err=%s ]\n", err) + kcl.cli.CoreV1().Pods(portainerNamespace).Delete(shellPod.Name, nil) + } + }() + + return podData, nil +} + +// waitForPodStatus will wait until duration d (from now) for a pod to reach defined phase/status. +// The pod status will be polled at specified delay until the pod reaches ready state. +func (kcl *KubeClient) waitForPodStatus(ctx context.Context, phase v1.PodPhase, pod *v1.Pod) error { + log.Printf("[DEBUG] [internal,kubernetes/pod] [message: waiting for pod ready: pod=%s... ]\n", pod.Name) + + pollDelay := 500 * time.Millisecond + for { + select { + case <-ctx.Done(): + return ctx.Err() + default: + pod, err := kcl.cli.CoreV1().Pods(pod.Namespace).Get(pod.Name, metav1.GetOptions{}) + if err != nil { + return err + } + + if pod.Status.Phase == phase { + return nil + } + + <-time.After(pollDelay) + } + } +} diff --git a/api/kubernetes/cli/pod_test.go b/api/kubernetes/cli/pod_test.go new file mode 100644 index 000000000..ad28d69d7 --- /dev/null +++ b/api/kubernetes/cli/pod_test.go @@ -0,0 +1,67 @@ +package cli + +import ( + "context" + "testing" + "time" + + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + kfake "k8s.io/client-go/kubernetes/fake" +) + +func Test_waitForPodStatus(t *testing.T) { + + t.Run("successfully errors on cancelled context", func(t *testing.T) { + k := &KubeClient{ + cli: kfake.NewSimpleClientset(), + instanceID: "test", + } + + podSpec := &v1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: defaultNamespace}, + Spec: v1.PodSpec{ + Containers: []v1.Container{ + {Name: "test-pod", Image: "containous/whoami"}, + }, + }, + } + + ctx, cancel := context.WithCancel(context.TODO()) + cancel() + err := k.waitForPodStatus(ctx, v1.PodRunning, podSpec) + if err != context.Canceled { + t.Errorf("waitForPodStatus should throw context cancellation error; err=%s", err) + } + }) + + t.Run("successfully errors on timeout", func(t *testing.T) { + k := &KubeClient{ + cli: kfake.NewSimpleClientset(), + instanceID: "test", + } + + podSpec := &v1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: defaultNamespace}, + Spec: v1.PodSpec{ + Containers: []v1.Container{ + {Name: "test-pod", Image: "containous/whoami"}, + }, + }, + } + + pod, err := k.cli.CoreV1().Pods(defaultNamespace).Create(podSpec) + if err != nil { + t.Errorf("failed to create pod; err=%s", err) + } + defer k.cli.CoreV1().Pods(defaultNamespace).Delete(pod.Name, nil) + + ctx, cancelFunc := context.WithTimeout(context.TODO(), 0*time.Second) + defer cancelFunc() + err = k.waitForPodStatus(ctx, v1.PodRunning, podSpec) + if err != context.DeadlineExceeded { + t.Errorf("waitForPodStatus should throw deadline exceeded error; err=%s", err) + } + }) + +} diff --git a/api/kubernetes/cli/resource.go b/api/kubernetes/cli/resource.go new file mode 100644 index 000000000..f3d69f573 --- /dev/null +++ b/api/kubernetes/cli/resource.go @@ -0,0 +1,27 @@ +package cli + +import ( + "bytes" + + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/serializer/json" +) + +func GenerateYAML(obj runtime.Object) (string, error) { + serializer := json.NewSerializerWithOptions( + json.DefaultMetaFactory, nil, nil, + json.SerializerOptions{ + Yaml: true, + Pretty: true, + Strict: true, + }, + ) + + b := new(bytes.Buffer) + err := serializer.Encode(obj, b) + if err != nil { + return "", err + } + + return b.String(), nil +} diff --git a/api/kubernetes/cli/resource_test.go b/api/kubernetes/cli/resource_test.go new file mode 100644 index 000000000..902671103 --- /dev/null +++ b/api/kubernetes/cli/resource_test.go @@ -0,0 +1,93 @@ +package cli + +import ( + "strings" + "testing" + + "k8s.io/apimachinery/pkg/runtime" + clientV1 "k8s.io/client-go/tools/clientcmd/api/v1" +) + +// compareYAMLStrings will compare 2 strings by stripping tabs, newlines and whitespaces from both strings +func compareYAMLStrings(in1, in2 string) int { + r := strings.NewReplacer("\t", "", "\n", "", " ", "") + in1 = r.Replace(in1) + in2 = r.Replace(in2) + return strings.Compare(in1, in2) +} + +func Test_GenerateYAML(t *testing.T) { + resourceYAMLTests := []struct { + title string + resource runtime.Object + wantYAML string + }{ + { + title: "Config", + resource: &clientV1.Config{ + APIVersion: "v1", + Kind: "Config", + CurrentContext: "portainer-ctx", + Contexts: []clientV1.NamedContext{ + { + Name: "portainer-ctx", + Context: clientV1.Context{ + AuthInfo: "test-user", + Cluster: "portainer-cluster", + }, + }, + }, + Clusters: []clientV1.NamedCluster{ + { + Name: "portainer-cluster", + Cluster: clientV1.Cluster{ + Server: "localhost", + InsecureSkipTLSVerify: true, + }, + }, + }, + AuthInfos: []clientV1.NamedAuthInfo{ + { + Name: "test-user", + AuthInfo: clientV1.AuthInfo{ + Token: "test-token", + }, + }, + }, + }, + wantYAML: ` + apiVersion: v1 + clusters: + - cluster: + insecure-skip-tls-verify: true + server: localhost + name: portainer-cluster + contexts: + - context: + cluster: portainer-cluster + user: test-user + name: portainer-ctx + current-context: portainer-ctx + kind: Config + preferences: {} + users: + - name: test-user + user: + token: test-token + `, + }, + } + + for _, ryt := range resourceYAMLTests { + t.Run(ryt.title, func(t *testing.T) { + yaml, err := GenerateYAML(ryt.resource) + if err != nil { + t.Errorf("generateYamlConfig failed; err=%s", err) + } + + if compareYAMLStrings(yaml, ryt.wantYAML) != 0 { + t.Errorf("generateYamlConfig failed;\ngot=\n%s\nwant=\n%s", yaml, ryt.wantYAML) + } + }) + } +} diff --git a/api/kubernetes/cli/service_account.go b/api/kubernetes/cli/service_account.go index c09d16141..ddb852b3c 100644 --- a/api/kubernetes/cli/service_account.go +++ b/api/kubernetes/cli/service_account.go @@ -1,12 +1,31 @@ package cli import ( - "k8s.io/api/core/v1" + portainer "github.com/portainer/portainer/api" + v1 "k8s.io/api/core/v1" rbacv1 "k8s.io/api/rbac/v1" k8serrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) +// GetServiceAccount returns the portainer ServiceAccountName associated to the specified user. +func (kcl *KubeClient) GetServiceAccount(tokenData *portainer.TokenData) (*v1.ServiceAccount, error) { + var portainerServiceAccountName string + if tokenData.Role == portainer.AdministratorRole { + portainerServiceAccountName = portainerClusterAdminServiceAccountName + } else { + portainerServiceAccountName = userServiceAccountName(int(tokenData.ID), kcl.instanceID) + } + + // verify name exists as service account resource within portainer namespace + serviceAccount, err := kcl.cli.CoreV1().ServiceAccounts(portainerNamespace).Get(portainerServiceAccountName, metav1.GetOptions{}) + if err != nil { + return nil, err + } + + return serviceAccount, nil +} + // GetServiceAccountBearerToken returns the ServiceAccountToken associated to the specified user. func (kcl *KubeClient) GetServiceAccountBearerToken(userID int) (string, error) { serviceAccountName := userServiceAccountName(userID, kcl.instanceID) diff --git a/api/kubernetes/cli/service_account_test.go b/api/kubernetes/cli/service_account_test.go new file mode 100644 index 000000000..defc4eb43 --- /dev/null +++ b/api/kubernetes/cli/service_account_test.go @@ -0,0 +1,92 @@ +package cli + +import ( + "testing" + + portainer "github.com/portainer/portainer/api" + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + kfake "k8s.io/client-go/kubernetes/fake" +) + +func Test_GetServiceAccount(t *testing.T) { + + t.Run("returns error if non-existent", func(t *testing.T) { + k := &KubeClient{ + cli: kfake.NewSimpleClientset(), + instanceID: "test", + } + tokenData := &portainer.TokenData{ID: 1} + _, err := k.GetServiceAccount(tokenData) + if err == nil { + t.Error("GetServiceAccount should fail with service account not found") + } + }) + + t.Run("succeeds for cluster admin role", func(t *testing.T) { + k := &KubeClient{ + cli: kfake.NewSimpleClientset(), + instanceID: "test", + } + + tokenData := &portainer.TokenData{ + ID: 1, + Role: portainer.AdministratorRole, + Username: portainerClusterAdminServiceAccountName, + } + serviceAccount := &v1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: tokenData.Username, + }, + } + _, err := k.cli.CoreV1().ServiceAccounts(portainerNamespace).Create(serviceAccount) + if err != nil { + t.Errorf("failed to create service acount; err=%s", err) + } + defer k.cli.CoreV1().ServiceAccounts(portainerNamespace).Delete(serviceAccount.Name, nil) + + sa, err := k.GetServiceAccount(tokenData) + if err != nil { + t.Errorf("GetServiceAccount should succeed; err=%s", err) + } + + want := "portainer-sa-clusteradmin" + if sa.Name != want { + t.Errorf("GetServiceAccount should succeed and return correct sa name; got=%s want=%s", sa.Name, want) + } + }) + + t.Run("succeeds for standard user role", func(t *testing.T) { + k := &KubeClient{ + cli: kfake.NewSimpleClientset(), + instanceID: "test", + } + + tokenData := &portainer.TokenData{ + ID: 1, + Role: portainer.StandardUserRole, + } + serviceAccountName := userServiceAccountName(int(tokenData.ID), k.instanceID) + serviceAccount := &v1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: serviceAccountName, + }, + } + _, err := k.cli.CoreV1().ServiceAccounts(portainerNamespace).Create(serviceAccount) + if err != nil { + t.Errorf("failed to create service acount; err=%s", err) + } + defer k.cli.CoreV1().ServiceAccounts(portainerNamespace).Delete(serviceAccount.Name, nil) + + sa, err := k.GetServiceAccount(tokenData) + if err != nil { + t.Errorf("GetServiceAccount should succeed; err=%s", err) + } + + want := "portainer-sa-user-test-1" + if sa.Name != want { + t.Errorf("GetServiceAccount should succeed and return correct sa name; got=%s want=%s", sa.Name, want) + } + }) + +} diff --git a/api/portainer.go b/api/portainer.go index b63ca3b2f..8615c1010 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -1,11 +1,14 @@ package portainer import ( + "context" "io" "net/http" "time" gittypes "github.com/portainer/portainer/api/git/types" + v1 "k8s.io/api/core/v1" + clientV1 "k8s.io/client-go/tools/clientcmd/api/v1" ) type ( @@ -436,6 +439,14 @@ type ( Type string `json:"Type"` } + // KubernetesShellPod represents a Kubectl Shell details to facilitate pod exec functionality + KubernetesShellPod struct { + Namespace string + PodName string + ContainerName string + ShellExecCommand string + } + // LDAPGroupSearchSettings represents settings used to search for groups in a LDAP server LDAPGroupSearchSettings struct { // The distinguished name of the element from which the LDAP server will search for groups @@ -1172,8 +1183,10 @@ type ( // KubeClient represents a service used to query a Kubernetes environment KubeClient interface { + GetServiceAccount(tokendata *TokenData) (*v1.ServiceAccount, error) SetupUserServiceAccount(userID int, teamIDs []int, restrictDefaultNamespace bool) error GetServiceAccountBearerToken(userID int) (string, error) + CreateUserShellPod(ctx context.Context, serviceAccountName string) (*KubernetesShellPod, 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) @@ -1181,6 +1194,7 @@ type ( DeleteRegistrySecret(registry *Registry, namespace string) error CreateRegistrySecret(registry *Registry, namespace string) error IsRegistrySecret(namespace, secretName string) (bool, error) + GetKubeConfig(ctx context.Context, apiServerURL string, bearerToken string, tokenData *TokenData) (*clientV1.Config, error) } // KubernetesDeployer represents a service to deploy a manifest inside a Kubernetes endpoint diff --git a/app/assets/css/app.css b/app/assets/css/app.css index 1989727d0..b38aaae88 100644 --- a/app/assets/css/app.css +++ b/app/assets/css/app.css @@ -6,6 +6,7 @@ body, #view { height: 100%; width: 100%; + overflow-y: initial; } #view { @@ -1028,3 +1029,9 @@ json-tree .branch-preview { margin-top: 2rem; margin-bottom: 2rem; } + +.kubectl-shell { + display: block; + text-align: center; + padding-bottom: 5px; +} diff --git a/app/assets/css/rdash.css b/app/assets/css/rdash.css index 8ee24cd8d..950d8fd8d 100644 --- a/app/assets/css/rdash.css +++ b/app/assets/css/rdash.css @@ -55,7 +55,6 @@ * Header */ .row.header { - height: 60px; background: #fff; margin-bottom: 15px; } diff --git a/app/constants.js b/app/constants.js index 57d1c7c41..c6d5dbabb 100644 --- a/app/constants.js +++ b/app/constants.js @@ -1,6 +1,7 @@ angular .module('portainer') .constant('API_ENDPOINT_AUTH', 'api/auth') + .constant('API_ENDPOINT_KUBERNETES', 'api/kubernetes') .constant('API_ENDPOINT_CUSTOM_TEMPLATES', 'api/custom_templates') .constant('API_ENDPOINT_EDGE_GROUPS', 'api/edge_groups') .constant('API_ENDPOINT_EDGE_JOBS', 'api/edge_jobs') diff --git a/app/kubernetes/components/kubectl-shell/kubectl-shell.controller.js b/app/kubernetes/components/kubectl-shell/kubectl-shell.controller.js new file mode 100644 index 000000000..15e31baef --- /dev/null +++ b/app/kubernetes/components/kubectl-shell/kubectl-shell.controller.js @@ -0,0 +1,125 @@ +import { Terminal } from 'xterm'; +import * as fit from 'xterm/lib/addons/fit/fit'; + +export default class KubectlShellController { + /* @ngInject */ + constructor(TerminalWindow, $window, $async, EndpointProvider, LocalStorage, KubernetesConfigService, Notifications) { + this.$async = $async; + this.$window = $window; + this.TerminalWindow = TerminalWindow; + this.EndpointProvider = EndpointProvider; + this.LocalStorage = LocalStorage; + this.KubernetesConfigService = KubernetesConfigService; + this.Notifications = Notifications; + } + + disconnect() { + this.state.checked = false; + this.state.icon = 'fas fa-window-minimize'; + this.state.shell.socket.close(); + this.state.shell.term.dispose(); + this.state.shell.connected = false; + this.TerminalWindow.terminalclose(); + } + + screenClear() { + this.state.shell.term.clear(); + } + + miniRestore() { + if (this.state.css === 'mini') { + this.state.css = 'normal'; + this.state.icon = 'fas fa-window-minimize'; + this.TerminalWindow.terminalopen(); + } else { + this.state.css = 'mini'; + this.state.icon = 'fas fa-window-restore'; + this.TerminalWindow.terminalclose(); + } + } + + configureSocketAndTerminal(socket, term) { + socket.onopen = function () { + const terminal_container = document.getElementById('terminal-container'); + term.open(terminal_container); + term.setOption('cursorBlink', true); + term.focus(); + term.fit(); + term.writeln('#Run kubectl commands inside here'); + term.writeln('#e.g. kubectl get all'); + term.writeln(''); + }; + + term.on('data', function (data) { + socket.send(data); + }); + + this.$window.onresize = function () { + term.fit(); + }; + + socket.onmessage = function (msg) { + term.write(msg.data); + }; + + socket.onerror = function (err) { + this.disconnect(); + this.Notifications.error('Failure', err, 'Websocket connection error'); + }.bind(this); + + this.state.shell.socket.onclose = this.disconnect.bind(this); + + this.state.shell.connected = true; + } + + connectConsole() { + this.TerminalWindow.terminalopen(); + this.state.checked = true; + this.state.css = 'normal'; + + const params = { + token: this.LocalStorage.getJWT(), + endpointId: this.EndpointProvider.endpointID(), + }; + + const wsProtocol = this.state.isHTTPS ? 'wss://' : 'ws://'; + const path = '/api/websocket/kubernetes-shell'; + const queryParams = Object.entries(params) + .map(([k, v]) => `${k}=${v}`) + .join('&'); + const url = `${wsProtocol}${window.location.host}${path}?${queryParams}`; + + Terminal.applyAddon(fit); + this.state.shell.socket = new WebSocket(url); + this.state.shell.term = new Terminal(); + + this.configureSocketAndTerminal(this.state.shell.socket, this.state.shell.term); + } + + async downloadKubeconfig() { + await this.KubernetesConfigService.downloadConfig(); + } + + $onInit() { + return this.$async(async () => { + this.state = { + css: 'normal', + checked: false, + icon: 'fa-window-minimize', + isHTTPS: this.$window.location.protocol === 'https:', + shell: { + connected: false, + socket: null, + term: null, + }, + }; + }); + } + + $onDestroy() { + if (this.state.shell.connected) { + this.disconnect(); + this.$window.onresize = null; + } + } +} diff --git a/app/kubernetes/components/kubectl-shell/kubectl-shell.css b/app/kubernetes/components/kubectl-shell/kubectl-shell.css new file mode 100644 index 000000000..969252f19 --- /dev/null +++ b/app/kubernetes/components/kubectl-shell/kubectl-shell.css @@ -0,0 +1,75 @@ +.shell-container { + display: flex; + justify-content: space-between; + color: #424242; + background: rgb(245, 245, 245); + border-top: 1px solid rgb(190, 190, 190); +} + +.shell-item { + width: 150px; + font-weight: bold; + text-align: center; + font-size: 14px; +} + +.shell-item-center { + flex-grow: 1; +} + +ul.sidebar li .shell-item-center a { + border-left: 1px solid rgb(143, 143, 143); + padding: 0px 5px; + color: #424242; +} + +ul.sidebar li .shell-item-center a:hover { + color: rgb(51, 122, 183); + text-decoration: underline; +} + +.shell-item-center a { + font-size: 12px; +} + +.shell-item-right { + height: 16px; + width: 180px; + text-align: right; + padding-right: 10px; +} + +.shell-item-right i { + padding: 0px 5px; + cursor: pointer; + color: rgb(89, 89, 89); + font-size: 14px; +} + +.shell-item-right i:hover { + color: rgb(51, 122, 183); +} + +.shell-item + .shell-item { + margin-left: 20%; +} + +.normal-kubectl-shell { + position: fixed; + background: #000; + bottom: 0; + left: 0; + width: 100vw; + height: 480px; + z-index: 1000; +} + +.mini-kubectl-shell { + position: fixed; + background: #000; + bottom: 0; + left: 0; + width: 100vw; + height: 35px; + z-index: 1000; +} diff --git a/app/kubernetes/components/kubectl-shell/kubectl-shell.html b/app/kubernetes/components/kubectl-shell/kubectl-shell.html new file mode 100644 index 000000000..3205e703f --- /dev/null +++ b/app/kubernetes/components/kubectl-shell/kubectl-shell.html @@ -0,0 +1,27 @@ + + +
+
+
kubectl shell
+ +
+ + + +
+
+
+
+
+
+
Loading Terminal...
+
+
+
+
+
diff --git a/app/kubernetes/components/kubectl-shell/kubectl-shell.js b/app/kubernetes/components/kubectl-shell/kubectl-shell.js new file mode 100644 index 000000000..a4e45d1fd --- /dev/null +++ b/app/kubernetes/components/kubectl-shell/kubectl-shell.js @@ -0,0 +1,8 @@ +import angular from 'angular'; +import controller from './kubectl-shell.controller'; +import './kubectl-shell.css'; + +angular.module('portainer.kubernetes').component('kubectlShell', { + templateUrl: './kubectl-shell.html', + controller, +}); diff --git a/app/kubernetes/rest/kubeconfig.js b/app/kubernetes/rest/kubeconfig.js new file mode 100644 index 000000000..8d3a689c2 --- /dev/null +++ b/app/kubernetes/rest/kubeconfig.js @@ -0,0 +1,20 @@ +import angular from 'angular'; + +angular.module('portainer.kubernetes').factory('KubernetesConfig', KubernetesConfigFactory); + +/* @ngInject */ +function KubernetesConfigFactory($http, EndpointProvider, API_ENDPOINT_KUBERNETES) { + return { get }; + + async function get() { + const endpointID = EndpointProvider.endpointID(); + return $http({ + method: 'GET', + url: `${API_ENDPOINT_KUBERNETES}/${endpointID}/config`, + responseType: 'blob', + headers: { + Accept: 'text/yaml', + }, + }); + } +} diff --git a/app/kubernetes/services/kubeconfigService.js b/app/kubernetes/services/kubeconfigService.js new file mode 100644 index 000000000..752dc1ef3 --- /dev/null +++ b/app/kubernetes/services/kubeconfigService.js @@ -0,0 +1,17 @@ +import angular from 'angular'; + +class KubernetesConfigService { + /* @ngInject */ + constructor(KubernetesConfig, FileSaver) { + this.KubernetesConfig = KubernetesConfig; + this.FileSaver = FileSaver; + } + + async downloadConfig() { + const response = await this.KubernetesConfig.get(); + return this.FileSaver.saveAs(response.data, 'config'); + } +} + +export default KubernetesConfigService; +angular.module('portainer.kubernetes').service('KubernetesConfigService', KubernetesConfigService); diff --git a/app/portainer/services/terminal-window.js b/app/portainer/services/terminal-window.js new file mode 100644 index 000000000..0464fa1d8 --- /dev/null +++ b/app/portainer/services/terminal-window.js @@ -0,0 +1,19 @@ +angular.module('portainer').service('TerminalWindow', function ($window) { + this.terminalopen = function () { + const terminalHeight = 480; + const contentWrapperHeight = $window.innerHeight; + const newContentWrapperHeight = contentWrapperHeight - terminalHeight; + document.getElementById('content-wrapper').style.height = newContentWrapperHeight + 'px'; + document.getElementById('content-wrapper').style.overflowY = 'auto'; + document.getElementById('sidebar-wrapper').style.height = newContentWrapperHeight + 'px'; + }; + this.terminalclose = function () { + const wrapperCSS = { + height: '100%', + overflowY: 'initial', + }; + document.getElementById('content-wrapper').style.height = wrapperCSS.height; + document.getElementById('content-wrapper').style.overflowY = wrapperCSS.overflowY; + document.getElementById('sidebar-wrapper').style.height = wrapperCSS.height; + }; +}); diff --git a/app/portainer/views/sidebar/sidebar.html b/app/portainer/views/sidebar/sidebar.html index c1be6661b..2c3ac5f45 100644 --- a/app/portainer/views/sidebar/sidebar.html +++ b/app/portainer/views/sidebar/sidebar.html @@ -12,7 +12,10 @@ - +