diff --git a/api/bolt/init.go b/api/bolt/init.go
index 9209cfc3a..1c997087e 100644
--- a/api/bolt/init.go
+++ b/api/bolt/init.go
@@ -45,6 +45,7 @@ func (store *Store) Init() error {
EdgeAgentCheckinInterval: portainer.DefaultEdgeAgentCheckinIntervalInSeconds,
TemplatesURL: portainer.DefaultTemplatesURL,
UserSessionTimeout: portainer.DefaultUserSessionTimeout,
+ KubeconfigExpiry: portainer.DefaultKubeconfigExpiry,
}
err = store.SettingsService.UpdateSettings(defaultSettings)
diff --git a/api/bolt/migrator/migrate_dbversion31.go b/api/bolt/migrator/migrate_dbversion31.go
index 52f6c569b..6c9f7f00b 100644
--- a/api/bolt/migrator/migrate_dbversion31.go
+++ b/api/bolt/migrator/migrate_dbversion31.go
@@ -24,6 +24,10 @@ func (m *Migrator) migrateDBVersionToDB32() error {
return err
}
+ if err := m.kubeconfigExpiryToDB32(); err != nil {
+ return err
+ }
+
return nil
}
@@ -211,3 +215,12 @@ func findResourcesToUpdateForDB32(dockerID string, volumesData map[string]interf
}
}
}
+
+func (m *Migrator) kubeconfigExpiryToDB32() error {
+ settings, err := m.settingsService.Settings()
+ if err != nil {
+ return err
+ }
+ settings.KubeconfigExpiry = portainer.DefaultKubeconfigExpiry
+ return m.settingsService.UpdateSettings(settings)
+}
\ No newline at end of file
diff --git a/api/cmd/portainer/main.go b/api/cmd/portainer/main.go
index 717c3fbc3..32074c7e5 100644
--- a/api/cmd/portainer/main.go
+++ b/api/cmd/portainer/main.go
@@ -114,7 +114,7 @@ func initJWTService(dataStore portainer.DataStore) (portainer.JWTService, error)
settings.UserSessionTimeout = portainer.DefaultUserSessionTimeout
dataStore.Settings().UpdateSettings(settings)
}
- jwtService, err := jwt.NewService(settings.UserSessionTimeout)
+ jwtService, err := jwt.NewService(settings.UserSessionTimeout, dataStore)
if err != nil {
return nil, err
}
diff --git a/api/http/handler/endpointproxy/proxy_kubernetes.go b/api/http/handler/endpointproxy/proxy_kubernetes.go
index 88a369486..82d922ca1 100644
--- a/api/http/handler/endpointproxy/proxy_kubernetes.go
+++ b/api/http/handler/endpointproxy/proxy_kubernetes.go
@@ -79,4 +79,4 @@ func (handler *Handler) proxyRequestsToKubernetesAPI(w http.ResponseWriter, r *h
http.StripPrefix(requestPrefix, proxy).ServeHTTP(w, r)
return nil
-}
\ No newline at end of file
+}
diff --git a/api/http/handler/handler.go b/api/http/handler/handler.go
index 263aac2ca..e1a80581c 100644
--- a/api/http/handler/handler.go
+++ b/api/http/handler/handler.go
@@ -69,7 +69,7 @@ type Handler struct {
}
// @title PortainerCE API
-// @version 2.1.1
+// @version 2.6.3
// @description.markdown api-description.md
// @termsOfService
diff --git a/api/http/handler/kubernetes/handler.go b/api/http/handler/kubernetes/handler.go
index c317ed346..015b063c9 100644
--- a/api/http/handler/kubernetes/handler.go
+++ b/api/http/handler/kubernetes/handler.go
@@ -20,6 +20,7 @@ type Handler struct {
dataStore portainer.DataStore
kubernetesClientFactory *cli.ClientFactory
authorizationService *authorization.Service
+ JwtService portainer.JWTService
}
// NewHandler creates a handler to process pre-proxied requests to external APIs.
@@ -39,6 +40,8 @@ func NewHandler(bouncer *security.RequestBouncer, authorizationService *authoriz
kubeRouter.PathPrefix("/config").Handler(
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.getKubernetesConfig))).Methods(http.MethodGet)
+ kubeRouter.PathPrefix("/nodes_limits").Handler(
+ bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.getKubernetesNodesLimits))).Methods(http.MethodGet)
// namespaces
// in the future this piece of code might be in another package (or a few different packages - namespaces/namespace?)
diff --git a/api/http/handler/kubernetes/kubernetes_config.go b/api/http/handler/kubernetes/kubernetes_config.go
index 972d34390..31734dcb1 100644
--- a/api/http/handler/kubernetes/kubernetes_config.go
+++ b/api/http/handler/kubernetes/kubernetes_config.go
@@ -3,14 +3,11 @@ 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"
@@ -46,16 +43,16 @@ func (handler *Handler) getKubernetesConfig(w http.ResponseWriter, r *http.Reque
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}
}
+ bearerToken, err := handler.JwtService.GenerateTokenForKubeconfig(tokenData)
+ if err != nil {
+ return &httperror.HandlerError{http.StatusInternalServerError, "Unable to generate JWT token", err}
+ }
+
cli, err := handler.kubernetesClientFactory.GetKubeClient(endpoint)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to create Kubernetes client", err}
@@ -84,20 +81,6 @@ func (handler *Handler) getKubernetesConfig(w http.ResponseWriter, r *http.Reque
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)
diff --git a/api/http/handler/kubernetes/kubernetes_nodes_limits.go b/api/http/handler/kubernetes/kubernetes_nodes_limits.go
new file mode 100644
index 000000000..5e1f06215
--- /dev/null
+++ b/api/http/handler/kubernetes/kubernetes_nodes_limits.go
@@ -0,0 +1,52 @@
+package kubernetes
+
+import (
+ 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"
+ "net/http"
+)
+
+// @id getKubernetesNodesLimits
+// @summary Get CPU and memory limits of all nodes within k8s cluster
+// @description Get CPU and memory limits of all nodes within k8s cluster
+// @description **Access policy**: authorized
+// @tags kubernetes
+// @security jwt
+// @accept json
+// @produce json
+// @param id path int true "Endpoint identifier"
+// @success 200 {object} K8sNodesLimits "Success"
+// @failure 400 "Invalid request"
+// @failure 401 "Unauthorized"
+// @failure 403 "Permission denied"
+// @failure 404 "Endpoint not found"
+// @failure 500 "Server error"
+// @router /kubernetes/{id}/nodes_limits [get]
+func (handler *Handler) getKubernetesNodesLimits(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}
+ }
+
+ cli, err := handler.kubernetesClientFactory.GetKubeClient(endpoint)
+ if err != nil {
+ return &httperror.HandlerError{http.StatusInternalServerError, "Unable to create Kubernetes client", err}
+ }
+
+ nodesLimits, err := cli.GetNodesLimits()
+ if err != nil {
+ return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve nodes limits", err}
+ }
+
+ return response.JSON(w, nodesLimits)
+}
diff --git a/api/http/handler/settings/settings_update.go b/api/http/handler/settings/settings_update.go
index 36150d536..7258170d9 100644
--- a/api/http/handler/settings/settings_update.go
+++ b/api/http/handler/settings/settings_update.go
@@ -32,6 +32,8 @@ type settingsUpdatePayload struct {
EnableEdgeComputeFeatures *bool `example:"true"`
// The duration of a user session
UserSessionTimeout *string `example:"5m"`
+ // The expiry of a Kubeconfig
+ KubeconfigExpiry *string `example:"24h" default:"0"`
// Whether telemetry is enabled
EnableTelemetry *bool `example:"false"`
}
@@ -52,6 +54,12 @@ func (payload *settingsUpdatePayload) Validate(r *http.Request) error {
return errors.New("Invalid user session timeout")
}
}
+ if payload.KubeconfigExpiry != nil {
+ _, err := time.ParseDuration(*payload.KubeconfigExpiry)
+ if err != nil {
+ return errors.New("Invalid Kubeconfig Expiry")
+ }
+ }
return nil
}
@@ -135,6 +143,10 @@ func (handler *Handler) settingsUpdate(w http.ResponseWriter, r *http.Request) *
settings.EdgeAgentCheckinInterval = *payload.EdgeAgentCheckinInterval
}
+ if payload.KubeconfigExpiry != nil {
+ settings.KubeconfigExpiry = *payload.KubeconfigExpiry
+ }
+
if payload.UserSessionTimeout != nil {
settings.UserSessionTimeout = *payload.UserSessionTimeout
diff --git a/api/http/server.go b/api/http/server.go
index 95f9dfa76..88ca1284f 100644
--- a/api/http/server.go
+++ b/api/http/server.go
@@ -161,6 +161,7 @@ func (server *Server) Start() error {
endpointProxyHandler.ReverseTunnelService = server.ReverseTunnelService
var kubernetesHandler = kubehandler.NewHandler(requestBouncer, server.AuthorizationService, server.DataStore, server.KubernetesClientFactory)
+ kubernetesHandler.JwtService = server.JWTService
var fileHandler = file.NewHandler(filepath.Join(server.AssetsPath, "public"))
diff --git a/api/internal/testhelpers/datastore.go b/api/internal/testhelpers/datastore.go
index 635508d9b..08e865c77 100644
--- a/api/internal/testhelpers/datastore.go
+++ b/api/internal/testhelpers/datastore.go
@@ -70,6 +70,21 @@ func NewDatastore(options ...datastoreOption) *datastore {
return &d
}
+
+type stubSettingsService struct {
+ settings *portainer.Settings
+}
+
+func (s *stubSettingsService) Settings() (*portainer.Settings, error) { return s.settings, nil }
+func (s *stubSettingsService) UpdateSettings(settings *portainer.Settings) error { return nil }
+
+func WithSettings(settings *portainer.Settings) datastoreOption {
+ return func(d *datastore) {
+ d.settings = &stubSettingsService{settings: settings}
+ }
+}
+
+
type stubUserService struct {
users []portainer.User
}
diff --git a/api/jwt/jwt.go b/api/jwt/jwt.go
index 2caf0840a..5786caf85 100644
--- a/api/jwt/jwt.go
+++ b/api/jwt/jwt.go
@@ -16,6 +16,7 @@ import (
type Service struct {
secret []byte
userSessionTimeout time.Duration
+ dataStore portainer.DataStore
}
type claims struct {
@@ -31,7 +32,7 @@ var (
)
// NewService initializes a new service. It will generate a random key that will be used to sign JWT tokens.
-func NewService(userSessionDuration string) (*Service, error) {
+func NewService(userSessionDuration string, dataStore portainer.DataStore) (*Service, error) {
userSessionTimeout, err := time.ParseDuration(userSessionDuration)
if err != nil {
return nil, err
@@ -45,19 +46,28 @@ func NewService(userSessionDuration string) (*Service, error) {
service := &Service{
secret,
userSessionTimeout,
+ dataStore,
}
return service, nil
}
+func (service *Service) defaultExpireAt() (int64) {
+ return time.Now().Add(service.userSessionTimeout).Unix()
+}
+
// GenerateToken generates a new JWT token.
func (service *Service) GenerateToken(data *portainer.TokenData) (string, error) {
- return service.generateSignedToken(data, nil)
+ return service.generateSignedToken(data, service.defaultExpireAt())
}
// GenerateTokenForOAuth generates a new JWT for OAuth login
// token expiry time from the OAuth provider is considered
func (service *Service) GenerateTokenForOAuth(data *portainer.TokenData, expiryTime *time.Time) (string, error) {
- return service.generateSignedToken(data, expiryTime)
+ expireAt := service.defaultExpireAt()
+ if expiryTime != nil && !expiryTime.IsZero() {
+ expireAt = expiryTime.Unix()
+ }
+ return service.generateSignedToken(data, expireAt)
}
// ParseAndVerifyToken parses a JWT token and verify its validity. It returns an error if token is invalid.
@@ -88,17 +98,13 @@ func (service *Service) SetUserSessionDuration(userSessionDuration time.Duration
service.userSessionTimeout = userSessionDuration
}
-func (service *Service) generateSignedToken(data *portainer.TokenData, expiryTime *time.Time) (string, error) {
- expireToken := time.Now().Add(service.userSessionTimeout).Unix()
- if expiryTime != nil && !expiryTime.IsZero() {
- expireToken = expiryTime.Unix()
- }
+func (service *Service) generateSignedToken(data *portainer.TokenData, expiresAt int64) (string, error) {
cl := claims{
UserID: int(data.ID),
Username: data.Username,
Role: int(data.Role),
StandardClaims: jwt.StandardClaims{
- ExpiresAt: expireToken,
+ ExpiresAt: expiresAt,
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, cl)
diff --git a/api/jwt/jwt_kubeconfig.go b/api/jwt/jwt_kubeconfig.go
new file mode 100644
index 000000000..544a481c1
--- /dev/null
+++ b/api/jwt/jwt_kubeconfig.go
@@ -0,0 +1,26 @@
+package jwt
+
+import (
+ portainer "github.com/portainer/portainer/api"
+ "time"
+)
+
+// GenerateTokenForKubeconfig generates a new JWT token for Kubeconfig
+func (service *Service) GenerateTokenForKubeconfig(data *portainer.TokenData) (string, error) {
+ settings, err := service.dataStore.Settings().Settings()
+ if err != nil {
+ return "", err
+ }
+
+ expiryDuration, err := time.ParseDuration(settings.KubeconfigExpiry)
+ if err != nil {
+ return "", err
+ }
+
+ expiryAt := time.Now().Add(expiryDuration).Unix()
+ if expiryDuration == time.Duration(0) {
+ expiryAt = 0
+ }
+
+ return service.generateSignedToken(data, expiryAt)
+}
diff --git a/api/jwt/jwt_kubeconfig_test.go b/api/jwt/jwt_kubeconfig_test.go
new file mode 100644
index 000000000..b8269b4ca
--- /dev/null
+++ b/api/jwt/jwt_kubeconfig_test.go
@@ -0,0 +1,81 @@
+package jwt
+
+import (
+ "github.com/dgrijalva/jwt-go"
+ portainer "github.com/portainer/portainer/api"
+ i "github.com/portainer/portainer/api/internal/testhelpers"
+ "github.com/stretchr/testify/assert"
+ "testing"
+)
+
+func TestService_GenerateTokenForKubeconfig(t *testing.T) {
+ type fields struct {
+ userSessionTimeout string
+ dataStore portainer.DataStore
+ }
+
+ type args struct {
+ data *portainer.TokenData
+ }
+
+ mySettings := &portainer.Settings{
+ KubeconfigExpiry: "0",
+ }
+
+ myFields := fields{
+ userSessionTimeout: "24h",
+ dataStore: i.NewDatastore(i.WithSettings(mySettings)),
+ }
+
+ myTokenData := &portainer.TokenData{
+ Username: "Joe",
+ ID: 1,
+ Role: 1,
+ }
+
+ myArgs := args{
+ data: myTokenData,
+ }
+
+ tests := []struct {
+ name string
+ fields fields
+ args args
+ wantExpiresAt int64
+ wantErr bool
+ }{
+ {
+ name: "kubeconfig no expiry",
+ fields: myFields,
+ args: myArgs,
+ wantExpiresAt: 0,
+ wantErr: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ service, err := NewService(tt.fields.userSessionTimeout, tt.fields.dataStore)
+ assert.NoError(t, err, "failed to create a copy of service")
+
+ got, err := service.GenerateTokenForKubeconfig(tt.args.data)
+ if (err != nil) != tt.wantErr {
+ t.Errorf("GenerateTokenForKubeconfig() error = %v, wantErr %v", err, tt.wantErr)
+ return
+ }
+
+ parsedToken, err := jwt.ParseWithClaims(got, &claims{}, func(token *jwt.Token) (interface{}, error) {
+ return service.secret, nil
+ })
+ assert.NoError(t, err, "failed to parse generated token")
+
+ tokenClaims, ok := parsedToken.Claims.(*claims)
+ assert.Equal(t, true, ok, "failed to claims out of generated ticket")
+
+ assert.Equal(t, myTokenData.Username, tokenClaims.Username)
+ assert.Equal(t, int(myTokenData.ID), tokenClaims.UserID)
+ assert.Equal(t, int(myTokenData.Role), tokenClaims.Role)
+ assert.Equal(t, tt.wantExpiresAt, tokenClaims.ExpiresAt)
+ })
+ }
+}
\ No newline at end of file
diff --git a/api/jwt/jwt_test.go b/api/jwt/jwt_test.go
index ce70f6308..2a18783e4 100644
--- a/api/jwt/jwt_test.go
+++ b/api/jwt/jwt_test.go
@@ -10,7 +10,7 @@ import (
)
func TestGenerateSignedToken(t *testing.T) {
- svc, err := NewService("24h")
+ svc, err := NewService("24h", nil)
assert.NoError(t, err, "failed to create a copy of service")
token := &portainer.TokenData{
@@ -18,9 +18,9 @@ func TestGenerateSignedToken(t *testing.T) {
ID: 1,
Role: 1,
}
- expirtationTime := time.Now().Add(1 * time.Hour)
+ expiresAt := time.Now().Add(1 * time.Hour).Unix()
- generatedToken, err := svc.generateSignedToken(token, &expirtationTime)
+ generatedToken, err := svc.generateSignedToken(token, expiresAt)
assert.NoError(t, err, "failed to generate a signed token")
parsedToken, err := jwt.ParseWithClaims(generatedToken, &claims{}, func(token *jwt.Token) (interface{}, error) {
@@ -34,5 +34,5 @@ func TestGenerateSignedToken(t *testing.T) {
assert.Equal(t, token.Username, tokenClaims.Username)
assert.Equal(t, int(token.ID), tokenClaims.UserID)
assert.Equal(t, int(token.Role), tokenClaims.Role)
- assert.Equal(t, expirtationTime.Unix(), tokenClaims.ExpiresAt)
+ assert.Equal(t, expiresAt, tokenClaims.ExpiresAt)
}
diff --git a/api/kubernetes/cli/nodes_limits.go b/api/kubernetes/cli/nodes_limits.go
new file mode 100644
index 000000000..9e0c044eb
--- /dev/null
+++ b/api/kubernetes/cli/nodes_limits.go
@@ -0,0 +1,42 @@
+package cli
+
+import (
+ portainer "github.com/portainer/portainer/api"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+// GetNodesLimits gets the CPU and Memory limits(unused resources) of all nodes in the current k8s endpoint connection
+func (kcl *KubeClient) GetNodesLimits() (portainer.K8sNodesLimits, error) {
+ nodesLimits := make(portainer.K8sNodesLimits)
+
+ nodes, err := kcl.cli.CoreV1().Nodes().List(metav1.ListOptions{})
+ if err != nil {
+ return nil, err
+ }
+
+ pods, err := kcl.cli.CoreV1().Pods("").List(metav1.ListOptions{})
+ if err != nil {
+ return nil, err
+ }
+
+ for _, item := range nodes.Items {
+ cpu := item.Status.Allocatable.Cpu().MilliValue()
+ memory := item.Status.Allocatable.Memory().Value()
+
+ nodesLimits[item.ObjectMeta.Name] = &portainer.K8sNodeLimits{
+ CPU: cpu,
+ Memory: memory,
+ }
+ }
+
+ for _, item := range pods.Items {
+ if nodeLimits, ok := nodesLimits[item.Spec.NodeName]; ok {
+ for _, container := range item.Spec.Containers {
+ nodeLimits.CPU -= container.Resources.Requests.Cpu().MilliValue()
+ nodeLimits.Memory -= container.Resources.Requests.Memory().Value()
+ }
+ }
+ }
+
+ return nodesLimits, nil
+}
diff --git a/api/kubernetes/cli/nodes_limits_test.go b/api/kubernetes/cli/nodes_limits_test.go
new file mode 100644
index 000000000..bf880c2ff
--- /dev/null
+++ b/api/kubernetes/cli/nodes_limits_test.go
@@ -0,0 +1,137 @@
+package cli
+
+import (
+ portainer "github.com/portainer/portainer/api"
+ "k8s.io/api/core/v1"
+ "k8s.io/apimachinery/pkg/api/resource"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/client-go/kubernetes"
+ kfake "k8s.io/client-go/kubernetes/fake"
+ "reflect"
+ "testing"
+)
+
+func newNodes() *v1.NodeList {
+ return &v1.NodeList{
+ Items: []v1.Node{
+ {
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "test-node-0",
+ },
+ Status: v1.NodeStatus{
+ Allocatable: v1.ResourceList{
+ v1.ResourceName(v1.ResourceCPU): resource.MustParse("2"),
+ v1.ResourceName(v1.ResourceMemory): resource.MustParse("4M"),
+ },
+ },
+ },
+ {
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "test-node-1",
+ },
+ Status: v1.NodeStatus{
+ Allocatable: v1.ResourceList{
+ v1.ResourceName(v1.ResourceCPU): resource.MustParse("3"),
+ v1.ResourceName(v1.ResourceMemory): resource.MustParse("6M"),
+ },
+ },
+ },
+ },
+ }
+}
+
+func newPods() *v1.PodList {
+ return &v1.PodList{
+ Items: []v1.Pod{
+ {
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "test-container-0",
+ Namespace: "test-namespace-0",
+ },
+ Spec: v1.PodSpec{
+ NodeName: "test-node-0",
+ Containers: []v1.Container{
+ {
+ Name: "test-container-0",
+ Resources: v1.ResourceRequirements{
+ Requests: v1.ResourceList{
+ v1.ResourceName(v1.ResourceCPU): resource.MustParse("1"),
+ v1.ResourceName(v1.ResourceMemory): resource.MustParse("2M"),
+ },
+ },
+ },
+ },
+ },
+ },
+ {
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "test-container-1",
+ Namespace: "test-namespace-1",
+ },
+ Spec: v1.PodSpec{
+ NodeName: "test-node-1",
+ Containers: []v1.Container{
+ {
+ Name: "test-container-1",
+ Resources: v1.ResourceRequirements{
+ Requests: v1.ResourceList{
+ v1.ResourceName(v1.ResourceCPU): resource.MustParse("2"),
+ v1.ResourceName(v1.ResourceMemory): resource.MustParse("3M"),
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ }
+}
+
+func TestKubeClient_GetNodesLimits(t *testing.T) {
+ type fields struct {
+ cli kubernetes.Interface
+ }
+
+ fieldsInstance := fields{
+ cli: kfake.NewSimpleClientset(newNodes(), newPods()),
+ }
+
+ tests := []struct {
+ name string
+ fields fields
+ want portainer.K8sNodesLimits
+ wantErr bool
+ }{
+ {
+ name: "2 nodes 2 pods",
+ fields: fieldsInstance,
+ want: portainer.K8sNodesLimits{
+ "test-node-0": &portainer.K8sNodeLimits{
+ CPU: 1000,
+ Memory: 2000000,
+ },
+ "test-node-1": &portainer.K8sNodeLimits{
+ CPU: 1000,
+ Memory: 3000000,
+ },
+ },
+ wantErr: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ kcl := &KubeClient{
+ cli: tt.fields.cli,
+ }
+ got, err := kcl.GetNodesLimits()
+ if (err != nil) != tt.wantErr {
+ t.Errorf("GetNodesLimits() error = %v, wantErr %v", err, tt.wantErr)
+ return
+ }
+ if !reflect.DeepEqual(got, tt.want) {
+ t.Errorf("GetNodesLimits() got = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
diff --git a/api/kubernetes/cli/role.go b/api/kubernetes/cli/role.go
index d75afa3c1..7c22f1b32 100644
--- a/api/kubernetes/cli/role.go
+++ b/api/kubernetes/cli/role.go
@@ -18,15 +18,10 @@ func getPortainerUserDefaultPolicies() []rbacv1.PolicyRule {
Resources: []string{"storageclasses"},
APIGroups: []string{"storage.k8s.io"},
},
- {
- Verbs: []string{"list"},
- Resources: []string{"ingresses"},
- APIGroups: []string{"networking.k8s.io"},
- },
}
}
-func (kcl *KubeClient) createPortainerUserClusterRole() error {
+func (kcl *KubeClient) upsertPortainerK8sClusterRoles() error {
clusterRole := &rbacv1.ClusterRole{
ObjectMeta: metav1.ObjectMeta{
Name: portainerUserCRName,
@@ -35,8 +30,13 @@ func (kcl *KubeClient) createPortainerUserClusterRole() error {
}
_, err := kcl.cli.RbacV1().ClusterRoles().Create(clusterRole)
- if err != nil && !k8serrors.IsAlreadyExists(err) {
- return err
+ if err != nil {
+ if k8serrors.IsAlreadyExists(err) {
+ _, err = kcl.cli.RbacV1().ClusterRoles().Update(clusterRole)
+ }
+ if err != nil {
+ return err
+ }
}
return nil
diff --git a/api/kubernetes/cli/service_account.go b/api/kubernetes/cli/service_account.go
index ddb852b3c..95dc4e897 100644
--- a/api/kubernetes/cli/service_account.go
+++ b/api/kubernetes/cli/service_account.go
@@ -63,7 +63,7 @@ func (kcl *KubeClient) SetupUserServiceAccount(userID int, teamIDs []int, restri
}
func (kcl *KubeClient) ensureRequiredResourcesExist() error {
- return kcl.createPortainerUserClusterRole()
+ return kcl.upsertPortainerK8sClusterRoles()
}
func (kcl *KubeClient) createUserServiceAccount(namespace, serviceAccountName string) error {
diff --git a/api/portainer.go b/api/portainer.go
index eb2e8ac72..a4387b2df 100644
--- a/api/portainer.go
+++ b/api/portainer.go
@@ -398,6 +398,13 @@ type (
// JobType represents a job type
JobType int
+ K8sNodeLimits struct {
+ CPU int64 `json:"CPU"`
+ Memory int64 `json:"Memory"`
+ }
+
+ K8sNodesLimits map[string]*K8sNodeLimits
+
K8sNamespaceAccessPolicy struct {
UserAccessPolicies UserAccessPolicies `json:"UserAccessPolicies"`
TeamAccessPolicies TeamAccessPolicies `json:"TeamAccessPolicies"`
@@ -682,6 +689,8 @@ type (
EnableEdgeComputeFeatures bool `json:"EnableEdgeComputeFeatures" example:""`
// The duration of a user session
UserSessionTimeout string `json:"UserSessionTimeout" example:"5m"`
+ // The expiry of a Kubeconfig
+ KubeconfigExpiry string `json:"KubeconfigExpiry" example:"24h"`
// Whether telemetry is enabled
EnableTelemetry bool `json:"EnableTelemetry" example:"false"`
@@ -1212,18 +1221,20 @@ type (
JWTService interface {
GenerateToken(data *TokenData) (string, error)
GenerateTokenForOAuth(data *TokenData, expiryTime *time.Time) (string, error)
+ GenerateTokenForKubeconfig(data *TokenData) (string, error)
ParseAndVerifyToken(token string) (*TokenData, error)
SetUserSessionDuration(userSessionDuration time.Duration)
}
// 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
+ GetServiceAccount(tokendata *TokenData) (*v1.ServiceAccount, 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
+ GetNodesLimits() (K8sNodesLimits, error)
GetNamespaceAccessPolicies() (map[string]K8sNamespaceAccessPolicy, error)
UpdateNamespaceAccessPolicies(accessPolicies map[string]K8sNamespaceAccessPolicy) error
DeleteRegistrySecret(registry *Registry, namespace string) error
@@ -1414,7 +1425,7 @@ type (
const (
// APIVersion is the version number of the Portainer API
- APIVersion = "2.6.2"
+ APIVersion = "2.6.3"
// DBVersion is the version number of the Portainer database
DBVersion = 32
// ComposeSyntaxMaxVersion is a maximum supported version of the docker compose syntax
@@ -1448,6 +1459,8 @@ const (
DefaultTemplatesURL = "https://raw.githubusercontent.com/portainer/templates/master/templates-2.0.json"
// DefaultUserSessionTimeout represents the default timeout after which the user session is cleared
DefaultUserSessionTimeout = "8h"
+ // DefaultUserSessionTimeout represents the default timeout after which the user session is cleared
+ DefaultKubeconfigExpiry = "0"
)
const (
diff --git a/app/kubernetes/components/kube-config-download-button/kube-config-download-button.controller.js b/app/kubernetes/components/kube-config-download-button/kube-config-download-button.controller.js
new file mode 100644
index 000000000..643dcefa0
--- /dev/null
+++ b/app/kubernetes/components/kube-config-download-button/kube-config-download-button.controller.js
@@ -0,0 +1,15 @@
+export default class KubeConfigController {
+ /* @ngInject */
+ constructor($window, KubernetesConfigService) {
+ this.$window = $window;
+ this.KubernetesConfigService = KubernetesConfigService;
+ }
+
+ async downloadKubeconfig() {
+ await this.KubernetesConfigService.downloadConfig();
+ }
+
+ $onInit() {
+ this.state = { isHTTPS: this.$window.location.protocol === 'https:' };
+ }
+}
diff --git a/app/kubernetes/components/kube-config-download-button/kube-config-download-button.html b/app/kubernetes/components/kube-config-download-button/kube-config-download-button.html
new file mode 100644
index 000000000..b2bae2cdf
--- /dev/null
+++ b/app/kubernetes/components/kube-config-download-button/kube-config-download-button.html
@@ -0,0 +1,11 @@
+
diff --git a/app/kubernetes/components/kube-config-download-button/kube-config-download-button.js b/app/kubernetes/components/kube-config-download-button/kube-config-download-button.js
new file mode 100644
index 000000000..1732e50f2
--- /dev/null
+++ b/app/kubernetes/components/kube-config-download-button/kube-config-download-button.js
@@ -0,0 +1,7 @@
+import angular from 'angular';
+import controller from './kube-config-download-button.controller';
+
+angular.module('portainer.kubernetes').component('kubeConfigDownloadButton', {
+ templateUrl: './kube-config-download-button.html',
+ controller,
+});
diff --git a/app/kubernetes/components/kubectl-shell/kubectl-shell.controller.js b/app/kubernetes/components/kubectl-shell/kubectl-shell.controller.js
index 6fa2a5f58..d60ae0b36 100644
--- a/app/kubernetes/components/kubectl-shell/kubectl-shell.controller.js
+++ b/app/kubernetes/components/kubectl-shell/kubectl-shell.controller.js
@@ -3,13 +3,12 @@ import * as fit from 'xterm/lib/addons/fit/fit';
export default class KubectlShellController {
/* @ngInject */
- constructor(TerminalWindow, $window, $async, EndpointProvider, LocalStorage, KubernetesConfigService, Notifications) {
+ constructor(TerminalWindow, $window, $async, EndpointProvider, LocalStorage, Notifications) {
this.$async = $async;
this.$window = $window;
this.TerminalWindow = TerminalWindow;
this.EndpointProvider = EndpointProvider;
this.LocalStorage = LocalStorage;
- this.KubernetesConfigService = KubernetesConfigService;
this.Notifications = Notifications;
}
@@ -83,7 +82,7 @@ export default class KubectlShellController {
endpointId: this.EndpointProvider.endpointID(),
};
- const wsProtocol = this.state.isHTTPS ? 'wss://' : 'ws://';
+ const wsProtocol = this.$window.location.protocol === 'https:' ? 'wss://' : 'ws://';
const path = '/api/websocket/kubernetes-shell';
const queryParams = Object.entries(params)
.map(([k, v]) => `${k}=${v}`)
@@ -97,17 +96,12 @@ export default class KubectlShellController {
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,
diff --git a/app/kubernetes/components/kubectl-shell/kubectl-shell.html b/app/kubernetes/components/kubectl-shell/kubectl-shell.html
index a88209326..76dae9afe 100644
--- a/app/kubernetes/components/kubectl-shell/kubectl-shell.html
+++ b/app/kubernetes/components/kubectl-shell/kubectl-shell.html
@@ -1,14 +1,12 @@
+