mirror of https://github.com/portainer/portainer
resolve conflicts
commit
d191e4f9b9
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -79,4 +79,4 @@ func (handler *Handler) proxyRequestsToKubernetesAPI(w http.ResponseWriter, r *h
|
|||
|
||||
http.StripPrefix(requestPrefix, proxy).ServeHTTP(w, r)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
|
|
@ -69,7 +69,7 @@ type Handler struct {
|
|||
}
|
||||
|
||||
// @title PortainerCE API
|
||||
// @version 2.1.1
|
||||
// @version 2.6.3
|
||||
// @description.markdown api-description.md
|
||||
// @termsOfService
|
||||
|
||||
|
|
|
@ -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?)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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"))
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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:' };
|
||||
}
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
<button
|
||||
ng-if="$ctrl.state.isHTTPS"
|
||||
type="button"
|
||||
class="btn btn-xs btn-primary"
|
||||
ng-click="$ctrl.downloadKubeconfig()"
|
||||
analytics-on
|
||||
analytics-category="kubernetes"
|
||||
analytics-event="kubernetes-kubectl-kubeconfig"
|
||||
>
|
||||
Kubeconfig <i class="fas fa-download space-right"></i>
|
||||
</button>
|
|
@ -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,
|
||||
});
|
|
@ -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,
|
||||
|
|
|
@ -1,14 +1,12 @@
|
|||
<button type="button" class="btn btn-xs btn-primary" ng-click="$ctrl.connectConsole()" ng-disabled="$ctrl.state.shell.connected" data-cy="k8sSidebar-shellButton">
|
||||
<i class="fa fa-terminal" style="margin-right: 2px;"></i>
|
||||
kubectl shell
|
||||
<i class="fa fa-terminal space-right"></i> kubectl shell
|
||||
</button>
|
||||
|
||||
<kube-config-download-button></kube-config-download-button>
|
||||
|
||||
<div ng-if="$ctrl.state.checked" class="{{ $ctrl.state.css }}-kubectl-shell">
|
||||
<div class="shell-container">
|
||||
<div class="shell-item"><i class="fas fa-terminal" style="margin-right: 5px;"></i>kubectl shell</div>
|
||||
<div ng-if="$ctrl.state.isHTTPS" class="shell-item-center">
|
||||
<a href="" ng-click="$ctrl.downloadKubeconfig()"><i class="fas fa-file-download" style="margin-right: 5px;"></i>Download Kubeconfig</a>
|
||||
</div>
|
||||
<div class="shell-item-right">
|
||||
<i class="fas fa-redo-alt" ng-click="$ctrl.screenClear();" data-cy="k8sShell-refreshButton"></i>
|
||||
<i
|
||||
|
|
|
@ -19,10 +19,10 @@ export class KubernetesIngressConverter {
|
|||
: _.map(rule.http.paths, (path) => {
|
||||
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 {
|
||||
|
|
|
@ -20,10 +20,15 @@ export function KubernetesIngressRuleCreatePayload() {
|
|||
|
||||
export function KubernetesIngressRulePathCreatePayload() {
|
||||
return {
|
||||
backend: {
|
||||
serviceName: '',
|
||||
servicePort: 0,
|
||||
},
|
||||
path: '',
|
||||
pathType: 'ImplementationSpecific',
|
||||
backend: {
|
||||
service: {
|
||||
name: '',
|
||||
port: {
|
||||
number: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
{
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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) }),
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
|
@ -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();
|
||||
|
|
|
@ -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);
|
|
@ -98,7 +98,6 @@
|
|||
</editor-description>
|
||||
</web-editor-form>
|
||||
<!-- #endregion -->
|
||||
|
||||
<div ng-if="ctrl.state.appType === ctrl.KubernetesDeploymentTypes.APPLICATION_FORM">
|
||||
<div class="col-sm-12 form-section-title">
|
||||
Application
|
||||
|
@ -118,6 +117,7 @@
|
|||
auto-focus
|
||||
required
|
||||
ng-disabled="ctrl.state.isEdit"
|
||||
data-cy="k8sAppCreate-applicationName"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -775,6 +775,13 @@
|
|||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" ng-if="ctrl.nodeLimitsOverflow()">
|
||||
<div class="col-sm-12 small text-danger">
|
||||
<i class="fa fa-exclamation-circle red-icon" aria-hidden="true" style="margin-right: 2px;"></i>
|
||||
These reservations would exceed the resources currently available in the cluster.
|
||||
</div>
|
||||
</div>
|
||||
<!-- !cpu-limit-input -->
|
||||
<!-- #endregion -->
|
||||
|
||||
|
@ -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"
|
||||
></kubernetes-summary-view>
|
||||
</div>
|
||||
|
||||
<div class="col-sm-12 form-section-title" ng-if="ctrl.state.appType !== ctrl.KubernetesDeploymentTypes.GIT">
|
||||
Actions
|
||||
</div>
|
||||
|
||||
<!-- #region ACTIONS -->
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -68,7 +68,9 @@
|
|||
<td>
|
||||
<span ng-if="ctrl.application.ApplicationOwner" style="margin-right: 5px;"> <i class="fas fa-user"></i> {{ ctrl.application.ApplicationOwner }} </span>
|
||||
<span> <i class="fas fa-clock"></i> {{ ctrl.application.CreationDate | getisodate }}</span>
|
||||
<span> <i class="fa fa-file-code space-left space-right" aria-hidden="true"></i> Deployed from {{ ctrl.state.appType }}</span>
|
||||
<span ng-if="ctrl.application.ApplicationOwner">
|
||||
<i class="fa fa-file-code space-left space-right" aria-hidden="true"></i> Deployed from {{ ctrl.state.appType }}</span
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
|
@ -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"
|
||||
>
|
||||
<i class="fas fa-history space-right" aria-hidden="true"></i>Rollback to previous configuration
|
||||
</button>
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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)"
|
||||
/>
|
||||
|
|
|
@ -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"
|
||||
>
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -9,7 +9,7 @@
|
|||
additional-files="$ctrl.stack.AdditionalFiles"
|
||||
></git-form-info-panel>
|
||||
|
||||
<git-form-auto-update-fieldset model="$ctrl.formValues.AutoUpdate" on-change="($ctrl.onChange)"></git-form-auto-update-fieldset>
|
||||
<git-form-auto-update-fieldset model="$ctrl.formValues.AutoUpdate" on-change="($ctrl.onChangeAutoUpdate)"></git-form-auto-update-fieldset>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<p>
|
||||
|
@ -31,14 +31,14 @@
|
|||
<environment-variables-panel
|
||||
ng-model="$ctrl.formValues.Env"
|
||||
explanation="These values will be used as substitutions in the stack file"
|
||||
on-change="($ctrl.handleEnvVarChange)"
|
||||
on-change="($ctrl.onChangeEnvVar)"
|
||||
></environment-variables-panel>
|
||||
|
||||
<button
|
||||
class="btn btn-sm btn-primary"
|
||||
ng-click="$ctrl.submit()"
|
||||
ng-if="!$ctrl.formValues.AutoUpdate.RepositoryAutomaticUpdates"
|
||||
ng-disabled="$ctrl.isSubmitButtonDisabled() || !$ctrl.redeployGitForm.$valid"
|
||||
ng-disabled="$ctrl.isSubmitButtonDisabled() || $ctrl.state.hasUnsavedChanges || !$ctrl.redeployGitForm.$valid"
|
||||
style="margin-top: 7px; margin-left: 0;"
|
||||
button-spinner="$ctrl.state.redeployInProgress"
|
||||
style="margin-top: 7px; margin-left: 0;"
|
||||
|
@ -51,7 +51,7 @@
|
|||
<button
|
||||
class="btn btn-sm btn-primary"
|
||||
ng-click="$ctrl.saveGitSettings()"
|
||||
ng-disabled="$ctrl.isSubmitButtonDisabled() || !$ctrl.redeployGitForm.$valid"
|
||||
ng-disabled="$ctrl.isSubmitButtonDisabled() || !$ctrl.state.hasUnsavedChanges || !$ctrl.redeployGitForm.$valid"
|
||||
style="margin-top: 7px; margin-left: 0;"
|
||||
button-spinner="$ctrl.state.inProgress"
|
||||
>
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import _ from 'lodash-es';
|
||||
|
||||
export const KEY_REGEX = /[a-zA-Z]([-_a-zA-Z0-9]*[a-zA-Z0-9])?/.source;
|
||||
|
||||
export const KEY_REGEX = /(.+)/.source;
|
||||
export const VALUE_REGEX = /(.*)?/.source;
|
||||
|
||||
const KEY_VALUE_REGEX = new RegExp(`^(${KEY_REGEX})\\s*=(${VALUE_REGEX})$`);
|
||||
|
@ -16,7 +15,7 @@ export function parseDotEnvFile(src) {
|
|||
return parseArrayOfStrings(
|
||||
_.compact(src.split(NEWLINES_REGEX))
|
||||
.map((v) => v.trim())
|
||||
.filter((v) => !v.startsWith('#'))
|
||||
.filter((v) => !v.startsWith('#') && v !== '')
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -40,7 +39,7 @@ export function parseArrayOfStrings(array) {
|
|||
|
||||
const parsedKeyValArr = variableString.trim().match(KEY_VALUE_REGEX);
|
||||
if (parsedKeyValArr != null && parsedKeyValArr.length > 4) {
|
||||
return { name: parsedKeyValArr[1], value: parsedKeyValArr[3] || '' };
|
||||
return { name: parsedKeyValArr[1].trim(), value: parsedKeyValArr[3].trim() || '' };
|
||||
}
|
||||
})
|
||||
);
|
||||
|
|
|
@ -10,6 +10,7 @@ export function SettingsViewModel(data) {
|
|||
this.EnableEdgeComputeFeatures = data.EnableEdgeComputeFeatures;
|
||||
this.UserSessionTimeout = data.UserSessionTimeout;
|
||||
this.EnableTelemetry = data.EnableTelemetry;
|
||||
this.KubeconfigExpiry = data.KubeconfigExpiry;
|
||||
}
|
||||
|
||||
export function PublicSettingsViewModel(settings) {
|
||||
|
|
|
@ -118,6 +118,24 @@
|
|||
</div>
|
||||
</div>
|
||||
<!-- !edge -->
|
||||
<!-- kube -->
|
||||
<div class="col-sm-12 form-section-title">
|
||||
Kubernetes
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="edge_checkin" class="col-sm-2 control-label text-left">
|
||||
Kubeconfig expiry
|
||||
</label>
|
||||
<div class="col-sm-10">
|
||||
<select
|
||||
id="kubeconfig_expiry"
|
||||
class="form-control"
|
||||
ng-model="settings.KubeconfigExpiry"
|
||||
ng-options="opt.value as opt.key for opt in state.availableKubeconfigExpiryOptions"
|
||||
></select>
|
||||
</div>
|
||||
</div>
|
||||
<!-- ! kube -->
|
||||
<!-- actions -->
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
|
@ -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 "$@"
|
|
@ -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"]
|
|
@ -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(';');
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Reference in New Issue