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 8436b098c..de44257c1 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/handler/stacks/create_kubernetes_stack.go b/api/http/handler/stacks/create_kubernetes_stack.go
index e189e73a8..63aff8220 100644
--- a/api/http/handler/stacks/create_kubernetes_stack.go
+++ b/api/http/handler/stacks/create_kubernetes_stack.go
@@ -103,7 +103,12 @@ func (handler *Handler) createKubernetesStackFromFileContent(w http.ResponseWrit
stackFolder := strconv.Itoa(int(stack.ID))
projectPath, err := handler.FileService.StoreStackFileFromBytes(stackFolder, stack.EntryPoint, []byte(payload.StackFileContent))
if err != nil {
- return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to persist Kubernetes manifest file on disk", Err: err}
+ fileType := "Manifest"
+ if stack.IsComposeFormat {
+ fileType = "Compose"
+ }
+ errMsg := fmt.Sprintf("Unable to persist Kubernetes %s file on disk", fileType)
+ return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: errMsg, Err: err}
}
stack.ProjectPath = projectPath
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 0a5e4134b..8cfbaf86d 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
@@ -1415,7 +1426,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
@@ -1449,6 +1460,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 @@
+
+
kubectl shell
-
{
const ingRule = new KubernetesIngressRule();
ingRule.IngressName = data.metadata.name;
- ingRule.ServiceName = path.backend.serviceName;
+ ingRule.ServiceName = path.backend.service.name;
ingRule.Host = rule.host || '';
ingRule.IP = data.status.loadBalancer.ingress ? data.status.loadBalancer.ingress[0].ip : undefined;
- ingRule.Port = path.backend.servicePort;
+ ingRule.Port = path.backend.service.port.number;
ingRule.Path = path.path;
return ingRule;
});
@@ -151,8 +151,8 @@ export class KubernetesIngressConverter {
rule.http.paths = _.map(paths, (p) => {
const path = new KubernetesIngressRulePathCreatePayload();
path.path = p.Path;
- path.backend.serviceName = p.ServiceName;
- path.backend.servicePort = p.Port;
+ path.backend.service.name = p.ServiceName;
+ path.backend.service.port.number = p.Port;
return path;
});
hostsWithRules.push(host);
@@ -173,7 +173,7 @@ export class KubernetesIngressConverter {
res.spec.rules = [];
_.forEach(data.Hosts, (host) => {
if (!host.NeedsDeletion) {
- res.spec.rules.push({ host: host.Host });
+ res.spec.rules.push({ host: host.Host || host });
}
});
} else {
diff --git a/app/kubernetes/ingress/payloads.js b/app/kubernetes/ingress/payloads.js
index d1c6279ec..f5411b7c0 100644
--- a/app/kubernetes/ingress/payloads.js
+++ b/app/kubernetes/ingress/payloads.js
@@ -20,10 +20,15 @@ export function KubernetesIngressRuleCreatePayload() {
export function KubernetesIngressRulePathCreatePayload() {
return {
- backend: {
- serviceName: '',
- servicePort: 0,
- },
path: '',
+ pathType: 'ImplementationSpecific',
+ backend: {
+ service: {
+ name: '',
+ port: {
+ number: 0,
+ },
+ },
+ },
};
}
diff --git a/app/kubernetes/ingress/rest.js b/app/kubernetes/ingress/rest.js
index df335653b..3bdeaf10f 100644
--- a/app/kubernetes/ingress/rest.js
+++ b/app/kubernetes/ingress/rest.js
@@ -5,7 +5,7 @@ angular.module('portainer.kubernetes').factory('KubernetesIngresses', factory);
function factory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) {
'use strict';
return function (namespace) {
- const url = `${API_ENDPOINT_ENDPOINTS}/:endpointId/kubernetes/apis/networking.k8s.io/v1beta1${namespace ? '/namespaces/:namespace' : ''}/ingresses/:id/:action`;
+ const url = `${API_ENDPOINT_ENDPOINTS}/:endpointId/kubernetes/apis/networking.k8s.io/v1${namespace ? '/namespaces/:namespace' : ''}/ingresses/:id/:action`;
return $resource(
url,
{
diff --git a/app/kubernetes/models/nodes-limits/models.js b/app/kubernetes/models/nodes-limits/models.js
new file mode 100644
index 000000000..b2f94254b
--- /dev/null
+++ b/app/kubernetes/models/nodes-limits/models.js
@@ -0,0 +1,65 @@
+import _ from 'lodash-es';
+
+/**
+ * NodesLimits Model
+ */
+export class KubernetesNodesLimits {
+ constructor(nodesLimits) {
+ this.MaxCPU = 0;
+ this.MaxMemory = 0;
+ this.nodesLimits = this.convertCPU(nodesLimits);
+
+ this.calculateMaxCPUMemory();
+ }
+
+ convertCPU(nodesLimits) {
+ _.forEach(nodesLimits, (value) => {
+ if (value.CPU) {
+ value.CPU /= 1000.0;
+ }
+ });
+ return nodesLimits;
+ }
+
+ calculateMaxCPUMemory() {
+ const nodesLimitsArray = Object.values(this.nodesLimits);
+ this.MaxCPU = _.maxBy(nodesLimitsArray, 'CPU').CPU;
+ this.MaxMemory = _.maxBy(nodesLimitsArray, 'Memory').Memory;
+ }
+
+ // check if there is enough cpu and memory to allocate containers in replica mode
+ overflowForReplica(cpu, memory, instances) {
+ _.forEach(this.nodesLimits, (value) => {
+ instances -= Math.min(Math.floor(value.CPU / cpu), Math.floor(value.Memory / memory));
+ });
+
+ return instances > 0;
+ }
+
+ // check if there is enough cpu and memory to allocate containers in global mode
+ overflowForGlobal(cpu, memory) {
+ let overflow = false;
+
+ _.forEach(this.nodesLimits, (value) => {
+ if (cpu > value.CPU || memory > value.Memory) {
+ overflow = true;
+ }
+ });
+
+ return overflow;
+ }
+
+ excludesPods(pods, cpuLimit, memoryLimit) {
+ const nodesLimits = this.nodesLimits;
+
+ _.forEach(pods, (value) => {
+ const node = value.Node;
+ if (node && nodesLimits[node]) {
+ nodesLimits[node].CPU += cpuLimit;
+ nodesLimits[node].Memory += memoryLimit;
+ }
+ });
+
+ this.calculateMaxCPUMemory();
+ }
+}
diff --git a/app/kubernetes/rest/nodesLimits.js b/app/kubernetes/rest/nodesLimits.js
new file mode 100644
index 000000000..d8eb59d93
--- /dev/null
+++ b/app/kubernetes/rest/nodesLimits.js
@@ -0,0 +1,21 @@
+import angular from 'angular';
+
+angular.module('portainer.kubernetes').factory('KubernetesNodesLimits', KubernetesNodesLimitsFactory);
+
+/* @ngInject */
+function KubernetesNodesLimitsFactory($resource, API_ENDPOINT_KUBERNETES, EndpointProvider) {
+ const url = API_ENDPOINT_KUBERNETES + '/:endpointId/nodes_limits';
+ return $resource(
+ url,
+ {
+ endpointId: EndpointProvider.endpointID,
+ },
+ {
+ get: {
+ method: 'GET',
+ ignoreLoadingBar: true,
+ transformResponse: (data) => ({ data: JSON.parse(data) }),
+ },
+ }
+ );
+}
diff --git a/app/kubernetes/services/kubeconfigService.js b/app/kubernetes/services/kubeconfigService.js
index 8b78ca9f7..a3dd1d2fc 100644
--- a/app/kubernetes/services/kubeconfigService.js
+++ b/app/kubernetes/services/kubeconfigService.js
@@ -9,7 +9,6 @@ class KubernetesConfigService {
async downloadConfig() {
const response = await this.KubernetesConfig.get();
-
const headers = response.headers();
const contentDispositionHeader = headers['content-disposition'];
const filename = contentDispositionHeader.replace('attachment;', '').trim();
diff --git a/app/kubernetes/services/nodesLimitsService.js b/app/kubernetes/services/nodesLimitsService.js
new file mode 100644
index 000000000..14a4f3275
--- /dev/null
+++ b/app/kubernetes/services/nodesLimitsService.js
@@ -0,0 +1,25 @@
+import angular from 'angular';
+import PortainerError from 'Portainer/error';
+import { KubernetesNodesLimits } from 'Kubernetes/models/nodes-limits/models';
+
+class KubernetesNodesLimitsService {
+ /* @ngInject */
+ constructor(KubernetesNodesLimits) {
+ this.KubernetesNodesLimits = KubernetesNodesLimits;
+ }
+
+ /**
+ * GET
+ */
+ async get() {
+ try {
+ const nodesLimits = await this.KubernetesNodesLimits.get().$promise;
+ return new KubernetesNodesLimits(nodesLimits.data);
+ } catch (err) {
+ throw new PortainerError('Unable to retrieve nodes limits', err);
+ }
+ }
+}
+
+export default KubernetesNodesLimitsService;
+angular.module('portainer.kubernetes').service('KubernetesNodesLimitsService', KubernetesNodesLimitsService);
diff --git a/app/kubernetes/views/applications/create/createApplication.html b/app/kubernetes/views/applications/create/createApplication.html
index 5f4a52c99..348065721 100644
--- a/app/kubernetes/views/applications/create/createApplication.html
+++ b/app/kubernetes/views/applications/create/createApplication.html
@@ -98,7 +98,6 @@
-
Application
@@ -118,6 +117,7 @@
auto-focus
required
ng-disabled="ctrl.state.isEdit"
+ data-cy="k8sAppCreate-applicationName"
/>
@@ -775,6 +775,13 @@
+
+
@@ -1396,7 +1403,7 @@
class="form-control"
name="ingress_class_{{ $index }}"
ng-model="publishedPort.IngressName"
- ng-options="ingress.Name as ingress.Name for ingress in ctrl.filteredIngresses"
+ ng-options="ingress.Name as ingress.Name for ingress in ctrl.ingresses"
ng-required="!publishedPort.NeedsDeletion"
ng-change="ctrl.onChangePortMappingIngress($index)"
ng-disabled="ctrl.disableLoadBalancerEdit() || ctrl.isEditAndNotNewPublishedPort($index)"
@@ -1603,10 +1610,10 @@
old-form-values="ctrl.savedFormValues"
>
+
Actions
-
diff --git a/app/kubernetes/views/applications/create/createApplicationController.js b/app/kubernetes/views/applications/create/createApplicationController.js
index bded4257f..f713c12aa 100644
--- a/app/kubernetes/views/applications/create/createApplicationController.js
+++ b/app/kubernetes/views/applications/create/createApplicationController.js
@@ -51,7 +51,8 @@ class KubernetesCreateApplicationController {
KubernetesPersistentVolumeClaimService,
KubernetesVolumeService,
RegistryService,
- StackService
+ StackService,
+ KubernetesNodesLimitsService
) {
this.$async = $async;
this.$state = $state;
@@ -68,6 +69,7 @@ class KubernetesCreateApplicationController {
this.KubernetesPersistentVolumeClaimService = KubernetesPersistentVolumeClaimService;
this.RegistryService = RegistryService;
this.StackService = StackService;
+ this.KubernetesNodesLimitsService = KubernetesNodesLimitsService;
this.ApplicationDeploymentTypes = KubernetesApplicationDeploymentTypes;
this.ApplicationDataAccessPolicies = KubernetesApplicationDataAccessPolicies;
@@ -98,6 +100,10 @@ class KubernetesCreateApplicationController {
memory: 0,
cpu: 0,
},
+ namespaceLimits: {
+ memory: 0,
+ cpu: 0,
+ },
resourcePoolHasQuota: false,
viewReady: false,
availableSizeUnits: ['MB', 'GB', 'TB'],
@@ -162,6 +168,7 @@ class KubernetesCreateApplicationController {
}
this.state.updateWebEditorInProgress = true;
await this.StackService.updateKubeStack({ EndpointId: this.endpoint.Id, Id: this.application.StackId }, this.stackFileContent, null);
+ this.state.isEditorDirty = false;
await this.$state.reload();
} catch (err) {
this.Notifications.error('Failure', err, 'Failed redeploying application');
@@ -171,6 +178,12 @@ class KubernetesCreateApplicationController {
});
}
+ async uiCanExit() {
+ if (this.stackFileContent && this.state.isEditorDirty) {
+ return this.ModalService.confirmWebEditorDiscard();
+ }
+ }
+
setPullImageValidity(validity) {
this.state.pullImageValidity = validity;
}
@@ -406,7 +419,7 @@ class KubernetesCreateApplicationController {
/* #region PUBLISHED PORTS UI MANAGEMENT */
addPublishedPort() {
const p = new KubernetesApplicationPublishedPortFormValue();
- const ingresses = this.filteredIngresses;
+ const ingresses = this.ingresses;
p.IngressName = ingresses && ingresses.length ? ingresses[0].Name : undefined;
p.IngressHost = ingresses && ingresses.length ? ingresses[0].Hosts[0] : undefined;
p.IngressHosts = ingresses && ingresses.length ? ingresses[0].Hosts : undefined;
@@ -417,7 +430,7 @@ class KubernetesCreateApplicationController {
}
resetPublishedPorts() {
- const ingresses = this.filteredIngresses;
+ const ingresses = this.ingresses;
_.forEach(this.formValues.PublishedPorts, (p) => {
p.IngressName = ingresses && ingresses.length ? ingresses[0].Name : undefined;
p.IngressHost = ingresses && ingresses.length ? ingresses[0].Hosts[0] : undefined;
@@ -476,7 +489,7 @@ class KubernetesCreateApplicationController {
onChangePortMappingIngress(index) {
const publishedPort = this.formValues.PublishedPorts[index];
- const ingress = _.find(this.filteredIngresses, { Name: publishedPort.IngressName });
+ const ingress = _.find(this.ingresses, { Name: publishedPort.IngressName });
publishedPort.IngressHosts = ingress.Hosts;
this.ingressHostnames = ingress.Hosts;
publishedPort.IngressHost = this.ingressHostnames.length ? this.ingressHostnames[0] : [];
@@ -624,14 +637,28 @@ class KubernetesCreateApplicationController {
return !this.state.sliders.memory.max || !this.state.sliders.cpu.max;
}
- resourceReservationsOverflow() {
- const instances = this.formValues.ReplicaCount;
+ nodeLimitsOverflow() {
const cpu = this.formValues.CpuLimit;
- const maxCpu = this.state.sliders.cpu.max;
- const memory = this.formValues.MemoryLimit;
- const maxMemory = this.state.sliders.memory.max;
+ const memory = KubernetesResourceReservationHelper.bytesValue(this.formValues.MemoryLimit);
- if (cpu * instances > maxCpu) {
+ const overflow = this.nodesLimits.overflowForReplica(cpu, memory, 1);
+
+ return overflow;
+ }
+
+ effectiveInstances() {
+ return this.formValues.DeploymentType === this.ApplicationDeploymentTypes.GLOBAL ? this.nodeNumber : this.formValues.ReplicaCount;
+ }
+
+ resourceReservationsOverflow() {
+ const instances = this.effectiveInstances();
+ const cpu = this.formValues.CpuLimit;
+ const maxCpu = this.state.namespaceLimits.cpu;
+ const memory = KubernetesResourceReservationHelper.bytesValue(this.formValues.MemoryLimit);
+ const maxMemory = this.state.namespaceLimits.memory;
+
+ // multiply 1000 can avoid 0.1 * 3 > 0.3
+ if (cpu * 1000 * instances > maxCpu * 1000) {
return true;
}
@@ -639,17 +666,23 @@ class KubernetesCreateApplicationController {
return true;
}
- return false;
+ if (this.formValues.DeploymentType === this.ApplicationDeploymentTypes.REPLICATED) {
+ return this.nodesLimits.overflowForReplica(cpu, memory, instances);
+ }
+
+ // DeploymentType == GLOBAL
+ return this.nodesLimits.overflowForGlobal(cpu, memory);
}
autoScalerOverflow() {
const instances = this.formValues.AutoScaler.MaxReplicas;
const cpu = this.formValues.CpuLimit;
- const maxCpu = this.state.sliders.cpu.max;
- const memory = this.formValues.MemoryLimit;
- const maxMemory = this.state.sliders.memory.max;
+ const maxCpu = this.state.namespaceLimits.cpu;
+ const memory = KubernetesResourceReservationHelper.bytesValue(this.formValues.MemoryLimit);
+ const maxMemory = this.state.namespaceLimits.memory;
- if (cpu * instances > maxCpu) {
+ // multiply 1000 can avoid 0.1 * 3 > 0.3
+ if (cpu * 1000 * instances > maxCpu * 1000) {
return true;
}
@@ -657,7 +690,7 @@ class KubernetesCreateApplicationController {
return true;
}
- return false;
+ return this.nodesLimits.overflowForReplica(cpu, memory, instances);
}
publishViaLoadBalancerEnabled() {
@@ -665,7 +698,7 @@ class KubernetesCreateApplicationController {
}
publishViaIngressEnabled() {
- return this.filteredIngresses.length;
+ return this.ingresses.length;
}
isEditAndNoChangesMade() {
@@ -773,50 +806,66 @@ class KubernetesCreateApplicationController {
/* #region DATA AUTO REFRESH */
updateSliders() {
+ const quota = this.formValues.ResourcePool.Quota;
+ let minCpu = 0,
+ minMemory = 0,
+ maxCpu = this.state.namespaceLimits.cpu,
+ maxMemory = this.state.namespaceLimits.memory;
+
+ if (quota) {
+ if (quota.CpuLimit) {
+ minCpu = KubernetesApplicationQuotaDefaults.CpuLimit;
+ }
+ if (quota.MemoryLimit) {
+ minMemory = KubernetesResourceReservationHelper.bytesValue(KubernetesApplicationQuotaDefaults.MemoryLimit);
+ }
+ }
+
+ maxCpu = Math.min(maxCpu, this.nodesLimits.MaxCPU);
+ maxMemory = Math.min(maxMemory, this.nodesLimits.MaxMemory);
+
+ if (maxMemory < minMemory) {
+ minMemory = 0;
+ maxMemory = 0;
+ }
+
+ this.state.sliders.memory.min = KubernetesResourceReservationHelper.megaBytesValue(minMemory);
+ this.state.sliders.memory.max = KubernetesResourceReservationHelper.megaBytesValue(maxMemory);
+ this.state.sliders.cpu.min = minCpu;
+ this.state.sliders.cpu.max = _.floor(maxCpu, 2);
+ if (!this.state.isEdit) {
+ this.formValues.CpuLimit = minCpu;
+ this.formValues.MemoryLimit = KubernetesResourceReservationHelper.megaBytesValue(minMemory);
+ }
+ }
+
+ updateNamespaceLimits() {
+ let maxCpu = this.state.nodes.cpu;
+ let maxMemory = this.state.nodes.memory;
+ const quota = this.formValues.ResourcePool.Quota;
+
this.state.resourcePoolHasQuota = false;
- const quota = this.formValues.ResourcePool.Quota;
- let minCpu,
- maxCpu,
- minMemory,
- maxMemory = 0;
if (quota) {
if (quota.CpuLimit) {
this.state.resourcePoolHasQuota = true;
- minCpu = KubernetesApplicationQuotaDefaults.CpuLimit;
maxCpu = quota.CpuLimit - quota.CpuLimitUsed;
if (this.state.isEdit && this.savedFormValues.CpuLimit) {
- maxCpu += this.savedFormValues.CpuLimit * this.savedFormValues.ReplicaCount;
+ maxCpu += this.savedFormValues.CpuLimit * this.effectiveInstances();
}
- } else {
- minCpu = 0;
- maxCpu = this.state.nodes.cpu;
}
+
if (quota.MemoryLimit) {
this.state.resourcePoolHasQuota = true;
- minMemory = KubernetesApplicationQuotaDefaults.MemoryLimit;
maxMemory = quota.MemoryLimit - quota.MemoryLimitUsed;
if (this.state.isEdit && this.savedFormValues.MemoryLimit) {
- maxMemory += KubernetesResourceReservationHelper.bytesValue(this.savedFormValues.MemoryLimit) * this.savedFormValues.ReplicaCount;
+ maxMemory += KubernetesResourceReservationHelper.bytesValue(this.savedFormValues.MemoryLimit) * this.effectiveInstances();
}
- } else {
- minMemory = 0;
- maxMemory = this.state.nodes.memory;
}
- } else {
- minCpu = 0;
- maxCpu = this.state.nodes.cpu;
- minMemory = 0;
- maxMemory = this.state.nodes.memory;
- }
- this.state.sliders.memory.min = minMemory;
- this.state.sliders.memory.max = KubernetesResourceReservationHelper.megaBytesValue(maxMemory);
- this.state.sliders.cpu.min = minCpu;
- this.state.sliders.cpu.max = _.round(maxCpu, 2);
- if (!this.state.isEdit) {
- this.formValues.CpuLimit = minCpu;
- this.formValues.MemoryLimit = minMemory;
}
+
+ this.state.namespaceLimits.cpu = maxCpu;
+ this.state.namespaceLimits.memory = maxMemory;
}
refreshStacks(namespace) {
@@ -870,16 +919,22 @@ class KubernetesCreateApplicationController {
}
refreshIngresses(namespace) {
- this.filteredIngresses = _.filter(this.ingresses, { Namespace: namespace });
- this.ingressHostnames = this.filteredIngresses.length ? this.filteredIngresses[0].Hosts : [];
- if (!this.publishViaIngressEnabled()) {
- if (this.savedFormValues) {
- this.formValues.PublishingType = this.savedFormValues.PublishingType;
- } else {
- this.formValues.PublishingType = this.ApplicationPublishingTypes.INTERNAL;
+ return this.$async(async () => {
+ try {
+ this.ingresses = await this.KubernetesIngressService.get(namespace);
+ this.ingressHostnames = this.ingresses.length ? this.ingresses[0].Hosts : [];
+ if (!this.publishViaIngressEnabled()) {
+ if (this.savedFormValues) {
+ this.formValues.PublishingType = this.savedFormValues.PublishingType;
+ } else {
+ this.formValues.PublishingType = this.ApplicationPublishingTypes.INTERNAL;
+ }
+ }
+ this.formValues.OriginalIngresses = this.ingresses;
+ } catch (err) {
+ this.Notifications.error('Failure', err, 'Unable to retrieve ingresses');
}
- }
- this.formValues.OriginalIngresses = this.filteredIngresses;
+ });
}
refreshNamespaceData(namespace) {
@@ -904,6 +959,7 @@ class KubernetesCreateApplicationController {
onResourcePoolSelectionChange() {
return this.$async(async () => {
const namespace = this.formValues.ResourcePool.Namespace.Name;
+ this.updateNamespaceLimits();
this.updateSliders();
await this.refreshNamespaceData(namespace);
this.resetFormValues();
@@ -988,12 +1044,12 @@ class KubernetesCreateApplicationController {
this.state.useLoadBalancer = this.endpoint.Kubernetes.Configuration.UseLoadBalancer;
this.state.useServerMetrics = this.endpoint.Kubernetes.Configuration.UseServerMetrics;
- const [resourcePools, nodes, ingresses] = await Promise.all([
+ const [resourcePools, nodes, nodesLimits] = await Promise.all([
this.KubernetesResourcePoolService.get(),
this.KubernetesNodeService.get(),
- this.KubernetesIngressService.get(),
+ this.KubernetesNodesLimitsService.get(),
]);
- this.ingresses = ingresses;
+ this.nodesLimits = nodesLimits;
this.resourcePools = _.filter(resourcePools, (resourcePool) => !KubernetesNamespaceHelper.isSystemNamespace(resourcePool.Namespace.Name));
this.formValues.ResourcePool = this.resourcePools[0];
@@ -1006,6 +1062,7 @@ class KubernetesCreateApplicationController {
this.state.nodes.cpu += item.CPU;
});
this.nodesLabels = KubernetesNodeHelper.generateNodeLabelsFromNodes(nodes);
+ this.nodeNumber = nodes.length;
const namespace = this.state.isEdit ? this.$state.params.namespace : this.formValues.ResourcePool.Namespace.Name;
await this.refreshNamespaceData(namespace);
@@ -1018,7 +1075,7 @@ class KubernetesCreateApplicationController {
this.configurations,
this.persistentVolumeClaims,
this.nodesLabels,
- this.filteredIngresses
+ this.ingresses
);
if (this.application.ApplicationKind) {
@@ -1031,7 +1088,7 @@ class KubernetesCreateApplicationController {
}
}
- this.formValues.OriginalIngresses = this.filteredIngresses;
+ this.formValues.OriginalIngresses = this.ingresses;
this.formValues.ImageModel = await this.parseImageConfiguration(this.formValues.ImageModel);
this.savedFormValues = angular.copy(this.formValues);
delete this.formValues.ApplicationType;
@@ -1048,8 +1105,13 @@ class KubernetesCreateApplicationController {
await this.refreshNamespaceData(namespace);
} else {
this.formValues.AutoScaler = KubernetesApplicationHelper.generateAutoScalerFormValueFromHorizontalPodAutoScaler(null, this.formValues.ReplicaCount);
- this.formValues.OriginalIngressClasses = angular.copy(this.ingresses);
}
+
+ if (this.state.isEdit) {
+ this.nodesLimits.excludesPods(this.application.Pods, this.formValues.CpuLimit, KubernetesResourceReservationHelper.bytesValue(this.formValues.MemoryLimit));
+ }
+
+ this.updateNamespaceLimits();
this.updateSliders();
} catch (err) {
this.Notifications.error('Failure', err, 'Unable to load view data');
diff --git a/app/kubernetes/views/applications/edit/application.html b/app/kubernetes/views/applications/edit/application.html
index 1e9e725a1..9a28c64c5 100644
--- a/app/kubernetes/views/applications/edit/application.html
+++ b/app/kubernetes/views/applications/edit/application.html
@@ -68,7 +68,9 @@
{{ ctrl.application.ApplicationOwner }}
{{ ctrl.application.CreationDate | getisodate }}
- Deployed from {{ ctrl.state.appType }}
+
+ Deployed from {{ ctrl.state.appType }}
|
@@ -217,7 +219,7 @@
class="btn btn-sm btn-primary"
style="margin-left: 0; margin-bottom: 15px;"
ng-click="ctrl.rollbackApplication()"
- ng-disabled="ctrl.application.Revisions.length < 2"
+ ng-disabled="ctrl.application.Revisions.length < 2 || ctrl.state.appType !== ctrl.KubernetesDeploymentTypes.APPLICATION_FORM"
>
Rollback to previous configuration
diff --git a/app/kubernetes/views/resource-pools/edit/resourcePoolController.js b/app/kubernetes/views/resource-pools/edit/resourcePoolController.js
index a38e9ac78..a2f94db49 100644
--- a/app/kubernetes/views/resource-pools/edit/resourcePoolController.js
+++ b/app/kubernetes/views/resource-pools/edit/resourcePoolController.js
@@ -293,7 +293,7 @@ class KubernetesResourcePoolController {
this.state.ingressesLoading = true;
try {
const namespace = this.pool.Namespace.Name;
- this.allIngresses = await this.KubernetesIngressService.get();
+ this.allIngresses = await this.KubernetesIngressService.get(this.state.hasWriteAuthorization ? '' : namespace);
this.ingresses = _.filter(this.allIngresses, { Namespace: namespace });
_.forEach(this.ingresses, (ing) => {
ing.Namespace = namespace;
diff --git a/app/portainer/components/environment-variables-panel/environment-variables-simple-mode/environment-variables-simple-mode-item/environment-variables-simple-mode-item.controller.js b/app/portainer/components/environment-variables-panel/environment-variables-simple-mode/environment-variables-simple-mode-item/environment-variables-simple-mode-item.controller.js
index 2138e4f57..30d61e8d3 100644
--- a/app/portainer/components/environment-variables-panel/environment-variables-simple-mode/environment-variables-simple-mode-item/environment-variables-simple-mode-item.controller.js
+++ b/app/portainer/components/environment-variables-panel/environment-variables-simple-mode/environment-variables-simple-mode-item/environment-variables-simple-mode-item.controller.js
@@ -1,12 +1,4 @@
-import { KEY_REGEX, VALUE_REGEX } from '@/portainer/helpers/env-vars';
-
class EnvironmentVariablesSimpleModeItemController {
- /* @ngInject */
- constructor() {
- this.KEY_REGEX = KEY_REGEX;
- this.VALUE_REGEX = VALUE_REGEX;
- }
-
onChangeName(name) {
const fieldIsInvalid = typeof name === 'undefined';
if (fieldIsInvalid) {
diff --git a/app/portainer/components/environment-variables-panel/environment-variables-simple-mode/environment-variables-simple-mode-item/environment-variables-simple-mode-item.html b/app/portainer/components/environment-variables-panel/environment-variables-simple-mode/environment-variables-simple-mode-item/environment-variables-simple-mode-item.html
index c53af0699..27ace7bc2 100644
--- a/app/portainer/components/environment-variables-panel/environment-variables-simple-mode/environment-variables-simple-mode-item/environment-variables-simple-mode-item.html
+++ b/app/portainer/components/environment-variables-panel/environment-variables-simple-mode/environment-variables-simple-mode-item/environment-variables-simple-mode-item.html
@@ -9,7 +9,6 @@
placeholder="e.g. FOO"
ng-model="$ctrl.variable.name"
ng-disabled="$ctrl.variable.added"
- ng-pattern="$ctrl.KEY_REGEX"
ng-change="$ctrl.onChangeName($ctrl.variable.name)"
required
/>
@@ -36,7 +35,6 @@
ng-model="$ctrl.variable.value"
placeholder="e.g. bar"
ng-trim="false"
- ng-pattern="$ctrl.VALUE_REGEX"
name="value"
ng-change="$ctrl.onChangeValue($ctrl.variable.value)"
/>
diff --git a/app/portainer/components/forms/kubernetes-redeploy-app-git-form/kubernetes-redeploy-app-git-form.html b/app/portainer/components/forms/kubernetes-redeploy-app-git-form/kubernetes-redeploy-app-git-form.html
index 1266464ed..c41905ace 100644
--- a/app/portainer/components/forms/kubernetes-redeploy-app-git-form/kubernetes-redeploy-app-git-form.html
+++ b/app/portainer/components/forms/kubernetes-redeploy-app-git-form/kubernetes-redeploy-app-git-form.html
@@ -40,6 +40,7 @@
ng-disabled="$ctrl.isSubmitButtonDisabled() || !$ctrl.redeployGitForm.$valid"
style="margin-top: 7px; margin-left: 0;"
button-spinner="ctrl.state.redeployInProgress"
+ analytics-on
analytics-category="kubernetes"
analytics-event="kubernetes-application-edit-git-pull"
>
diff --git a/app/portainer/components/forms/stack-redeploy-git-form/stack-redeploy-git-form.controller.js b/app/portainer/components/forms/stack-redeploy-git-form/stack-redeploy-git-form.controller.js
index 717c15333..1d21ae75c 100644
--- a/app/portainer/components/forms/stack-redeploy-git-form/stack-redeploy-git-form.controller.js
+++ b/app/portainer/components/forms/stack-redeploy-git-form/stack-redeploy-git-form.controller.js
@@ -1,4 +1,5 @@
import uuidv4 from 'uuid/v4';
+
class StackRedeployGitFormController {
/* @ngInject */
constructor($async, $state, StackService, ModalService, Notifications, WebhookHelper, FormHelper) {
@@ -15,6 +16,7 @@ class StackRedeployGitFormController {
redeployInProgress: false,
showConfig: false,
isEdit: false,
+ hasUnsavedChanges: false,
};
this.formValues = {
@@ -34,11 +36,8 @@ class StackRedeployGitFormController {
this.onChange = this.onChange.bind(this);
this.onChangeRef = this.onChangeRef.bind(this);
- this.handleEnvVarChange = this.handleEnvVarChange.bind(this);
- }
-
- onChangeRef(value) {
- this.onChange({ RefName: value });
+ this.onChangeAutoUpdate = this.onChangeAutoUpdate.bind(this);
+ this.onChangeEnvVar = this.onChangeEnvVar.bind(this);
}
onChange(values) {
@@ -46,6 +45,25 @@ class StackRedeployGitFormController {
...this.formValues,
...values,
};
+
+ this.state.hasUnsavedChanges = angular.toJson(this.savedFormValues) !== angular.toJson(this.formValues);
+ }
+
+ onChangeRef(value) {
+ this.onChange({ RefName: value });
+ }
+
+ onChangeAutoUpdate(values) {
+ this.onChange({
+ AutoUpdate: {
+ ...this.formValues.AutoUpdate,
+ ...values,
+ },
+ });
+ }
+
+ onChangeEnvVar(value) {
+ this.onChange({ Env: value });
}
async submit() {
@@ -83,6 +101,8 @@ class StackRedeployGitFormController {
try {
this.state.inProgress = true;
await this.StackService.updateGitStackSettings(this.stack.Id, this.stack.EndpointId, this.FormHelper.removeInvalidEnvVars(this.formValues.Env), this.formValues);
+ this.savedFormValues = angular.copy(this.formValues);
+ this.state.hasUnsavedChanges = false;
this.Notifications.success('Save stack settings successfully');
} catch (err) {
this.Notifications.error('Failure', err, 'Unable to save stack settings');
@@ -96,10 +116,6 @@ class StackRedeployGitFormController {
return this.state.inProgress || this.state.redeployInProgress;
}
- handleEnvVarChange(value) {
- this.formValues.Env = value;
- }
-
$onInit() {
this.formValues.RefName = this.model.ReferenceName;
this.formValues.Env = this.stack.Env;
@@ -125,6 +141,8 @@ class StackRedeployGitFormController {
this.formValues.RepositoryAuthentication = true;
this.state.isEdit = true;
}
+
+ this.savedFormValues = angular.copy(this.formValues);
}
}
diff --git a/app/portainer/components/forms/stack-redeploy-git-form/stack-redeploy-git-form.html b/app/portainer/components/forms/stack-redeploy-git-form/stack-redeploy-git-form.html
index 1cb08d54f..6ea3ba9a5 100644
--- a/app/portainer/components/forms/stack-redeploy-git-form/stack-redeploy-git-form.html
+++ b/app/portainer/components/forms/stack-redeploy-git-form/stack-redeploy-git-form.html
@@ -9,7 +9,7 @@
additional-files="$ctrl.stack.AdditionalFiles"
>
-
+
+
+
+ Kubernetes
+
+
+
diff --git a/app/portainer/views/settings/settingsController.js b/app/portainer/views/settings/settingsController.js
index a5438ce10..8d7c48b3b 100644
--- a/app/portainer/views/settings/settingsController.js
+++ b/app/portainer/views/settings/settingsController.js
@@ -24,7 +24,28 @@ angular.module('portainer.app').controller('SettingsController', [
value: 30,
},
],
-
+ availableKubeconfigExpiryOptions: [
+ {
+ key: '1 day',
+ value: '24h',
+ },
+ {
+ key: '7 days',
+ value: `${24 * 7}h`,
+ },
+ {
+ key: '30 days',
+ value: `${24 * 30}h`,
+ },
+ {
+ key: '1 year',
+ value: `${24 * 30 * 12}h`,
+ },
+ {
+ key: 'No expiry',
+ value: '0',
+ },
+ ],
backupInProgress: false,
};
diff --git a/build/linux/dev-toolkit/run.sh b/build/linux/dev-toolkit/run.sh
new file mode 100755
index 000000000..d856cb7c1
--- /dev/null
+++ b/build/linux/dev-toolkit/run.sh
@@ -0,0 +1,99 @@
+#!/usr/bin/env bash
+
+# Script used to init the Portainer development environment inside the dev-toolkit image
+
+### COLOR OUTPUT ###
+
+ESeq="\x1b["
+RCol="$ESeq"'0m' # Text Reset
+
+# Regular Bold Underline High Intensity BoldHigh Intens Background High Intensity Backgrounds
+Bla="$ESeq"'0;30m'; BBla="$ESeq"'1;30m'; UBla="$ESeq"'4;30m'; IBla="$ESeq"'0;90m'; BIBla="$ESeq"'1;90m'; On_Bla="$ESeq"'40m'; On_IBla="$ESeq"'0;100m';
+Red="$ESeq"'0;31m'; BRed="$ESeq"'1;31m'; URed="$ESeq"'4;31m'; IRed="$ESeq"'0;91m'; BIRed="$ESeq"'1;91m'; On_Red="$ESeq"'41m'; On_IRed="$ESeq"'0;101m';
+Gre="$ESeq"'0;32m'; BGre="$ESeq"'1;32m'; UGre="$ESeq"'4;32m'; IGre="$ESeq"'0;92m'; BIGre="$ESeq"'1;92m'; On_Gre="$ESeq"'42m'; On_IGre="$ESeq"'0;102m';
+Yel="$ESeq"'0;33m'; BYel="$ESeq"'1;33m'; UYel="$ESeq"'4;33m'; IYel="$ESeq"'0;93m'; BIYel="$ESeq"'1;93m'; On_Yel="$ESeq"'43m'; On_IYel="$ESeq"'0;103m';
+Blu="$ESeq"'0;34m'; BBlu="$ESeq"'1;34m'; UBlu="$ESeq"'4;34m'; IBlu="$ESeq"'0;94m'; BIBlu="$ESeq"'1;94m'; On_Blu="$ESeq"'44m'; On_IBlu="$ESeq"'0;104m';
+Pur="$ESeq"'0;35m'; BPur="$ESeq"'1;35m'; UPur="$ESeq"'4;35m'; IPur="$ESeq"'0;95m'; BIPur="$ESeq"'1;95m'; On_Pur="$ESeq"'45m'; On_IPur="$ESeq"'0;105m';
+Cya="$ESeq"'0;36m'; BCya="$ESeq"'1;36m'; UCya="$ESeq"'4;36m'; ICya="$ESeq"'0;96m'; BICya="$ESeq"'1;96m'; On_Cya="$ESeq"'46m'; On_ICya="$ESeq"'0;106m';
+Whi="$ESeq"'0;37m'; BWhi="$ESeq"'1;37m'; UWhi="$ESeq"'4;37m'; IWhi="$ESeq"'0;97m'; BIWhi="$ESeq"'1;97m'; On_Whi="$ESeq"'47m'; On_IWhi="$ESeq"'0;107m';
+
+printSection() {
+ echo -e "${BIYel}>>>> ${BIWhi}${1}${RCol}"
+}
+
+info() {
+ echo -e "${BIWhi}${1}${RCol}"
+}
+
+success() {
+ echo -e "${BIGre}${1}${RCol}"
+}
+
+error() {
+ echo -e "${BIRed}${1}${RCol}"
+}
+
+errorAndExit() {
+ echo -e "${BIRed}${1}${RCol}"
+ exit 1
+}
+
+### !COLOR OUTPUT ###
+
+SETUP_FILE=/setup-done
+
+display_configuration() {
+ info "Portainer dev-toolkit container configuration"
+ info "Go version"
+ /usr/local/go/bin/go version
+ info "Node version"
+ node -v
+ info "Yarn version"
+ yarn -v
+ info "Docker version"
+ docker version
+}
+
+main() {
+ [[ -z $PUSER ]] && errorAndExit "Unable to find PUSER environment variable. Please ensure PUSER is set before running this script."
+ [[ -z $PUID ]] && errorAndExit "Unable to find PUID environment variable. Please ensure PUID is set before running this script."
+ [[ -z $PGID ]] && errorAndExit "Unable to find PGID environment variable. Please ensure PGID is set before running this script."
+ [[ -z $DOCKERGID ]] && errorAndExit "Unable to find DOCKERGID environment variable. Please ensure DOCKERGID is set before running this script."
+
+ if [[ -f "${SETUP_FILE}" ]]; then
+ info "Portainer dev-toolkit container already configured."
+ display_configuration
+ else
+ info "Creating user group..."
+ groupadd -g $PGID $PUSER
+
+ info "Creating user..."
+ useradd -l -u $PUID -g $PUSER $PUSER
+
+ info "Setting up home..."
+ install -d -m 0755 -o $PUSER -g $PUSER /home/$PUSER
+
+ info "Configuring Docker..."
+ groupadd -g $DOCKERGID docker
+ usermod -aG docker $PUSER
+
+ info "Configuring Go..."
+ echo "PATH=\"$PATH:/usr/local/go/bin\"" > /etc/environment
+
+ info "Configuring Git..."
+ su $PUSER -c "git config --global url.git@github.com:.insteadOf https://github.com/"
+
+ info "Configuring SSH..."
+ mkdir /home/$PUSER/.ssh
+ cp /host-ssh/* /home/$PUSER/.ssh/
+ chown -R $PUSER:$PUSER /home/$PUSER/.ssh
+
+ touch "${SETUP_FILE}"
+ success "Portainer dev-toolkit container successfully configured."
+
+ display_configuration
+ fi
+}
+
+main
+su $PUSER -s "$@"
\ No newline at end of file
diff --git a/build/linux/toolkit.Dockerfile b/build/linux/dev-toolkit/toolkit.Dockerfile
similarity index 52%
rename from build/linux/toolkit.Dockerfile
rename to build/linux/dev-toolkit/toolkit.Dockerfile
index dc3f901a2..32470cc3b 100644
--- a/build/linux/toolkit.Dockerfile
+++ b/build/linux/dev-toolkit/toolkit.Dockerfile
@@ -1,4 +1,4 @@
-FROM ubuntu
+FROM ubuntu:20.04
# Expose port for the Portainer UI and Edge server
EXPOSE 9000
@@ -14,13 +14,30 @@ ARG GO_VERSION=go1.16.6.linux-amd64
# Install packages
RUN apt-get update --fix-missing && apt-get install -qq \
- dialog \
- apt-utils \
- curl \
- build-essential \
- nodejs \
- git \
- wget
+ dialog \
+ apt-utils \
+ curl \
+ build-essential \
+ git \
+ wget \
+ apt-transport-https \
+ ca-certificates \
+ gnupg-agent \
+ software-properties-common
+
+# Install Docker CLI
+RUN curl -fsSL https://download.docker.com/linux/ubuntu/gpg | apt-key add - \
+ && add-apt-repository \
+ "deb [arch=amd64] https://download.docker.com/linux/ubuntu \
+ $(lsb_release -cs) \
+ stable" \
+ && apt-get update \
+ && apt-get install -y docker-ce-cli
+
+
+# Install NodeJS
+RUN curl -fsSL https://deb.nodesource.com/setup_14.x | bash - \
+ && apt-get install -y nodejs
# Install Yarn
RUN curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - \
@@ -33,8 +50,8 @@ RUN cd /tmp \
&& tar -xf ${GO_VERSION}.tar.gz \
&& mv go /usr/local
-# Configure Go
-ENV PATH "$PATH:/usr/local/go/bin"
+# Copy run script
+COPY run.sh /
+RUN chmod +x /run.sh
-# Confirm installation
-RUN go version && node -v && yarn -v
+ENTRYPOINT ["/run.sh"]
diff --git a/gruntfile.js b/gruntfile.js
index f9940229d..b0a1e2906 100644
--- a/gruntfile.js
+++ b/gruntfile.js
@@ -7,6 +7,7 @@ var arch = os.arch();
if (arch === 'x64') arch = 'amd64';
var portainer_data = '${PORTAINER_DATA:-/tmp/portainer}';
+var portainer_root = process.env.PORTAINER_PROJECT ? process.env.PORTAINER_PROJECT : process.env.PWD;
module.exports = function (grunt) {
loadGruntTasks(grunt, {
@@ -174,7 +175,9 @@ function shell_build_binary_azuredevops(p, a) {
function shell_run_container() {
return [
'docker rm -f portainer',
- 'docker run -d -p 8000:8000 -p 9000:9000 -v $(pwd)/dist:/app -v ' +
+ 'docker run -d -p 8000:8000 -p 9000:9000 -p 9443:9443 -v ' +
+ portainer_root +
+ '/dist:/app -v ' +
portainer_data +
':/data -v /var/run/docker.sock:/var/run/docker.sock:z -v /var/run/docker.sock:/var/run/alternative.sock:z -v /tmp:/tmp --name portainer portainer/base /app/portainer',
].join(';');
diff --git a/package.json b/package.json
index dc3c4abeb..c594562fc 100644
--- a/package.json
+++ b/package.json
@@ -2,7 +2,7 @@
"author": "Portainer.io",
"name": "portainer",
"homepage": "http://portainer.io",
- "version": "2.6.0",
+ "version": "2.6.3",
"repository": {
"type": "git",
"url": "git@github.com:portainer/portainer.git"