feat(ingress): autodetect ingress controllers EE-673 (#7712)

pull/7358/head^2
Dakota Walsh 2022-09-27 08:43:24 +13:00 committed by GitHub
parent c96551e410
commit 89eda13eb3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
48 changed files with 1252 additions and 1047 deletions

View File

@ -12,6 +12,7 @@ type (
Type string `json:"Type"` Type string `json:"Type"`
Availability bool `json:"Availability"` Availability bool `json:"Availability"`
New bool `json:"New"` New bool `json:"New"`
Used bool `json:"Used"`
} }
K8sIngressControllers []K8sIngressController K8sIngressControllers []K8sIngressController

View File

@ -2,11 +2,11 @@ package models
import "net/http" import "net/http"
type K8sNamespaceInfo struct { type K8sNamespaceDetails struct {
Name string `json:"Name"` Name string `json:"Name"`
Annotations map[string]string `json:"Annotations"` Annotations map[string]string `json:"Annotations"`
} }
func (r *K8sNamespaceInfo) Validate(request *http.Request) error { func (r *K8sNamespaceDetails) Validate(request *http.Request) error {
return nil return nil
} }

View File

@ -6,7 +6,11 @@ import (
) )
func (m *Migrator) migrateDBVersionToDB70() error { func (m *Migrator) migrateDBVersionToDB70() error {
// foreach endpoint log.Info().Msg("- add IngressAvailabilityPerNamespace field")
if err := m.addIngressAvailabilityPerNamespaceFieldDB70(); err != nil {
return err
}
endpoints, err := m.endpointService.Endpoints() endpoints, err := m.endpointService.Endpoints()
if err != nil { if err != nil {
return err return err
@ -45,3 +49,20 @@ func (m *Migrator) migrateDBVersionToDB70() error {
return nil return nil
} }
func (m *Migrator) addIngressAvailabilityPerNamespaceFieldDB70() error {
endpoints, err := m.endpointService.Endpoints()
if err != nil {
return err
}
for _, endpoint := range endpoints {
endpoint.Kubernetes.Configuration.IngressAvailabilityPerNamespace = true
err = m.endpointService.UpdateEndpoint(endpoint.ID, &endpoint)
if err != nil {
return err
}
}
return nil
}

View File

@ -53,6 +53,7 @@
"Kubernetes": { "Kubernetes": {
"Configuration": { "Configuration": {
"EnableResourceOverCommit": false, "EnableResourceOverCommit": false,
"IngressAvailabilityPerNamespace": true,
"IngressClasses": null, "IngressClasses": null,
"ResourceOverCommitPercentage": 0, "ResourceOverCommitPercentage": 0,
"RestrictDefaultNamespace": false, "RestrictDefaultNamespace": false,

View File

@ -13,20 +13,18 @@ func (handler *Handler) getKubernetesConfigMaps(w http.ResponseWriter, r *http.R
namespace, err := request.RetrieveRouteVariableValue(r, "namespace") namespace, err := request.RetrieveRouteVariableValue(r, "namespace")
if err != nil { if err != nil {
return &httperror.HandlerError{ return httperror.BadRequest(
StatusCode: http.StatusBadRequest, "Invalid namespace identifier route variable",
Message: "Invalid namespace identifier route variable", err,
Err: err, )
}
} }
configmaps, err := cli.GetConfigMapsAndSecrets(namespace) configmaps, err := cli.GetConfigMapsAndSecrets(namespace)
if err != nil { if err != nil {
return &httperror.HandlerError{ return httperror.InternalServerError(
StatusCode: http.StatusInternalServerError, "Unable to retrieve nodes limits",
Message: "Unable to retrieve nodes limits", err,
Err: err, )
}
} }
return response.JSON(w, configmaps) return response.JSON(w, configmaps)

View File

@ -24,10 +24,10 @@ type Handler struct {
*mux.Router *mux.Router
authorizationService *authorization.Service authorizationService *authorization.Service
dataStore dataservices.DataStore dataStore dataservices.DataStore
jwtService dataservices.JWTService
kubernetesClientFactory *cli.ClientFactory
kubeClusterAccessService kubernetes.KubeClusterAccessService
KubernetesClient portainer.KubeClient KubernetesClient portainer.KubeClient
kubernetesClientFactory *cli.ClientFactory
jwtService dataservices.JWTService
kubeClusterAccessService kubernetes.KubeClusterAccessService
} }
// NewHandler creates a handler to process pre-proxied requests to external APIs. // NewHandler creates a handler to process pre-proxied requests to external APIs.

View File

@ -1,7 +1,6 @@
package kubernetes package kubernetes
import ( import (
"fmt"
"net/http" "net/http"
httperror "github.com/portainer/libhttp/error" httperror "github.com/portainer/libhttp/error"
@ -15,35 +14,39 @@ import (
func (handler *Handler) getKubernetesIngressControllers(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { func (handler *Handler) getKubernetesIngressControllers(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id") endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id")
if err != nil { if err != nil {
return &httperror.HandlerError{ return httperror.BadRequest(
StatusCode: http.StatusBadRequest, "Invalid environment identifier route variable",
Message: "Invalid environment identifier route variable", err,
Err: err, )
}
} }
endpoint, err := handler.dataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID)) endpoint, err := handler.dataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID))
if err == portainerDsErrors.ErrObjectNotFound { if err == portainerDsErrors.ErrObjectNotFound {
return &httperror.HandlerError{ return httperror.NotFound(
StatusCode: http.StatusNotFound, "Unable to find an environment with the specified identifier inside the database",
Message: "Unable to find an environment with the specified identifier inside the database", err,
Err: err, )
}
} else if err != nil { } else if err != nil {
return &httperror.HandlerError{ return httperror.InternalServerError(
StatusCode: http.StatusInternalServerError, "Unable to find an environment with the specified identifier inside the database",
Message: "Unable to find an environment with the specified identifier inside the database", err,
Err: err, )
} }
allowedOnly, err := request.RetrieveBooleanQueryParameter(r, "allowedOnly", true)
if err != nil {
return httperror.BadRequest(
"Invalid allowedOnly boolean query parameter",
err,
)
} }
cli, err := handler.kubernetesClientFactory.GetKubeClient(endpoint) cli, err := handler.kubernetesClientFactory.GetKubeClient(endpoint)
if err != nil { if err != nil {
return &httperror.HandlerError{ return httperror.InternalServerError(
StatusCode: http.StatusInternalServerError, "Unable to create Kubernetes client",
Message: "Unable to create Kubernetes client", err,
Err: err, )
}
} }
controllers := cli.GetIngressControllers() controllers := cli.GetIngressControllers()
@ -66,11 +69,44 @@ func (handler *Handler) getKubernetesIngressControllers(w http.ResponseWriter, r
} }
if controllers[i].ClassName == a.Name { if controllers[i].ClassName == a.Name {
controllers[i].Availability = !a.Blocked controllers[i].Availability = !a.GloballyBlocked
} }
} }
// TODO: Update existingClasses to take care of New and remove no longer }
// existing classes.
// Update the database to match the list of found + modified controllers.
// This includes pruning out controllers which no longer exist.
var newClasses []portainer.KubernetesIngressClassConfig
for _, controller := range controllers {
var class portainer.KubernetesIngressClassConfig
class.Name = controller.ClassName
class.Type = controller.Type
class.GloballyBlocked = !controller.Availability
class.BlockedNamespaces = []string{}
newClasses = append(newClasses, class)
}
endpoint.Kubernetes.Configuration.IngressClasses = newClasses
err = handler.dataStore.Endpoint().UpdateEndpoint(
portainer.EndpointID(endpointID),
endpoint,
)
if err != nil {
return httperror.InternalServerError(
"Unable to store found IngressClasses inside the database",
err,
)
}
// If the allowedOnly query parameter was set. We need to prune out
// disallowed controllers from the response.
if allowedOnly {
var allowedControllers models.K8sIngressControllers
for _, controller := range controllers {
if controller.Availability {
allowedControllers = append(allowedControllers, controller)
}
}
controllers = allowedControllers
} }
return response.JSON(w, controllers) return response.JSON(w, controllers)
} }
@ -78,80 +114,84 @@ func (handler *Handler) getKubernetesIngressControllers(w http.ResponseWriter, r
func (handler *Handler) getKubernetesIngressControllersByNamespace(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { func (handler *Handler) getKubernetesIngressControllersByNamespace(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id") endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id")
if err != nil { if err != nil {
return &httperror.HandlerError{ return httperror.BadRequest(
StatusCode: http.StatusBadRequest, "Invalid environment identifier route variable",
Message: "Invalid environment identifier route variable", err,
Err: err, )
}
} }
endpoint, err := handler.dataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID)) endpoint, err := handler.dataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID))
if err == portainerDsErrors.ErrObjectNotFound { if err == portainerDsErrors.ErrObjectNotFound {
return &httperror.HandlerError{ return httperror.NotFound(
StatusCode: http.StatusNotFound, "Unable to find an environment with the specified identifier inside the database",
Message: "Unable to find an environment with the specified identifier inside the database", err,
Err: err, )
}
} else if err != nil { } else if err != nil {
return &httperror.HandlerError{ return httperror.InternalServerError(
StatusCode: http.StatusInternalServerError, "Unable to find an environment with the specified identifier inside the database",
Message: "Unable to find an environment with the specified identifier inside the database", err,
Err: err, )
}
} }
namespace, err := request.RetrieveRouteVariableValue(r, "namespace") namespace, err := request.RetrieveRouteVariableValue(r, "namespace")
if err != nil { if err != nil {
return &httperror.HandlerError{ return httperror.BadRequest(
StatusCode: http.StatusBadRequest, "Invalid namespace identifier route variable",
Message: "Invalid namespace identifier route variable", err,
Err: err, )
}
} }
cli, err := handler.kubernetesClientFactory.GetKubeClient(endpoint) cli := handler.KubernetesClient
if err != nil { currentControllers := cli.GetIngressControllers()
return &httperror.HandlerError{
StatusCode: http.StatusInternalServerError,
Message: "Unable to create Kubernetes client",
Err: err,
}
}
controllers := cli.GetIngressControllers()
existingClasses := endpoint.Kubernetes.Configuration.IngressClasses existingClasses := endpoint.Kubernetes.Configuration.IngressClasses
for i := range controllers { var updatedClasses []portainer.KubernetesIngressClassConfig
controllers[i].Availability = true var controllers models.K8sIngressControllers
controllers[i].New = true for i := range currentControllers {
var globallyblocked bool
currentControllers[i].Availability = true
currentControllers[i].New = true
var updatedClass portainer.KubernetesIngressClassConfig
updatedClass.Name = currentControllers[i].ClassName
updatedClass.Type = currentControllers[i].Type
// Check if the controller is blocked globally or in the current // Check if the controller is blocked globally or in the current
// namespace. // namespace.
for _, a := range existingClasses { for _, existingClass := range existingClasses {
if controllers[i].ClassName != a.Name { if currentControllers[i].ClassName != existingClass.Name {
continue continue
} }
controllers[i].New = false currentControllers[i].New = false
updatedClass.GloballyBlocked = existingClass.GloballyBlocked
updatedClass.BlockedNamespaces = existingClass.BlockedNamespaces
// If it's not blocked we're all done! globallyblocked = existingClass.GloballyBlocked
if !a.Blocked {
continue
}
// Global blocks. // Check if the current namespace is blocked.
if len(a.BlockedNamespaces) == 0 { for _, ns := range existingClass.BlockedNamespaces {
controllers[i].Availability = false
continue
}
// Also check the current namespace.
for _, ns := range a.BlockedNamespaces {
if namespace == ns { if namespace == ns {
controllers[i].Availability = false currentControllers[i].Availability = false
} }
} }
} }
// TODO: Update existingClasses to take care of New and remove no longer if !globallyblocked {
// existing classes. controllers = append(controllers, currentControllers[i])
}
updatedClasses = append(updatedClasses, updatedClass)
}
// Update the database to match the list of found controllers.
// This includes pruning out controllers which no longer exist.
endpoint.Kubernetes.Configuration.IngressClasses = updatedClasses
err = handler.dataStore.Endpoint().UpdateEndpoint(
portainer.EndpointID(endpointID),
endpoint,
)
if err != nil {
return httperror.InternalServerError(
"Unable to store found IngressClasses inside the database",
err,
)
} }
return response.JSON(w, controllers) return response.JSON(w, controllers)
} }
@ -159,167 +199,205 @@ func (handler *Handler) getKubernetesIngressControllersByNamespace(w http.Respon
func (handler *Handler) updateKubernetesIngressControllers(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { func (handler *Handler) updateKubernetesIngressControllers(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id") endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id")
if err != nil { if err != nil {
return &httperror.HandlerError{ return httperror.BadRequest(
StatusCode: http.StatusBadRequest, "Invalid environment identifier route variable",
Message: "Invalid environment identifier route variable", err,
Err: err, )
}
} }
endpoint, err := handler.dataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID)) endpoint, err := handler.dataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID))
if err == portainerDsErrors.ErrObjectNotFound { if err == portainerDsErrors.ErrObjectNotFound {
return &httperror.HandlerError{ return httperror.NotFound(
StatusCode: http.StatusNotFound, "Unable to find an environment with the specified identifier inside the database",
Message: "Unable to find an environment with the specified identifier inside the database", err,
Err: err, )
}
} else if err != nil { } else if err != nil {
return &httperror.HandlerError{ return httperror.InternalServerError(
StatusCode: http.StatusInternalServerError, "Unable to find an environment with the specified identifier inside the database",
Message: "Unable to find an environment with the specified identifier inside the database", err,
Err: err, )
}
} }
var payload models.K8sIngressControllers var payload models.K8sIngressControllers
err = request.DecodeAndValidateJSONPayload(r, &payload) err = request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil { if err != nil {
return &httperror.HandlerError{ return httperror.BadRequest(
StatusCode: http.StatusBadRequest, "Invalid request payload",
Message: "Invalid request payload", err,
Err: err, )
}
} }
classes := endpoint.Kubernetes.Configuration.IngressClasses cli, err := handler.kubernetesClientFactory.GetKubeClient(endpoint)
for _, p := range payload { if err != nil {
for i := range classes { return httperror.InternalServerError(
if p.ClassName == classes[i].Name { "Unable to create Kubernetes client",
classes[i].Blocked = !p.Availability err,
)
}
existingClasses := endpoint.Kubernetes.Configuration.IngressClasses
controllers := cli.GetIngressControllers()
for i := range controllers {
// Set existing class data. So that we don't accidentally overwrite it
// with blank data that isn't in the payload.
for ii := range existingClasses {
if controllers[i].ClassName == existingClasses[ii].Name {
controllers[i].Availability = !existingClasses[ii].GloballyBlocked
} }
} }
} }
endpoint.Kubernetes.Configuration.IngressClasses = classes
fmt.Printf("%#v\n", endpoint.Kubernetes.Configuration.IngressClasses) for _, p := range payload {
for i := range controllers {
// Now set new payload data
if p.ClassName == controllers[i].ClassName {
controllers[i].Availability = p.Availability
}
}
}
// Update the database to match the list of found + modified controllers.
// This includes pruning out controllers which no longer exist.
var newClasses []portainer.KubernetesIngressClassConfig
for _, controller := range controllers {
var class portainer.KubernetesIngressClassConfig
class.Name = controller.ClassName
class.Type = controller.Type
class.GloballyBlocked = !controller.Availability
class.BlockedNamespaces = []string{}
newClasses = append(newClasses, class)
}
endpoint.Kubernetes.Configuration.IngressClasses = newClasses
err = handler.dataStore.Endpoint().UpdateEndpoint( err = handler.dataStore.Endpoint().UpdateEndpoint(
portainer.EndpointID(endpointID), portainer.EndpointID(endpointID),
endpoint, endpoint,
) )
if err != nil { if err != nil {
return &httperror.HandlerError{ return httperror.InternalServerError(
StatusCode: http.StatusInternalServerError, "Unable to update the BlockedIngressClasses inside the database",
Message: "Unable to update the BlockedIngressClasses inside the database", err,
Err: err, )
}
} }
return nil return response.Empty(w)
} }
func (handler *Handler) updateKubernetesIngressControllersByNamespace(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { func (handler *Handler) updateKubernetesIngressControllersByNamespace(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id") endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id")
if err != nil { if err != nil {
return &httperror.HandlerError{ return httperror.BadRequest(
StatusCode: http.StatusBadRequest, "Invalid environment identifier route variable",
Message: "Invalid environment identifier route variable", err,
Err: err, )
}
} }
endpoint, err := handler.dataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID)) endpoint, err := handler.dataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID))
if err == portainerDsErrors.ErrObjectNotFound { if err == portainerDsErrors.ErrObjectNotFound {
return &httperror.HandlerError{ return httperror.NotFound(
StatusCode: http.StatusNotFound, "Unable to find an environment with the specified identifier inside the database",
Message: "Unable to find an environment with the specified identifier inside the database", err,
Err: err, )
}
} else if err != nil { } else if err != nil {
return &httperror.HandlerError{ return httperror.InternalServerError(
StatusCode: http.StatusInternalServerError, "Unable to find an environment with the specified identifier inside the database",
Message: "Unable to find an environment with the specified identifier inside the database", err,
Err: err, )
}
} }
namespace, err := request.RetrieveRouteVariableValue(r, "namespace") namespace, err := request.RetrieveRouteVariableValue(r, "namespace")
if err != nil { if err != nil {
return &httperror.HandlerError{ return httperror.BadRequest(
StatusCode: http.StatusBadRequest, "Invalid namespace identifier route variable",
Message: "Invalid namespace identifier route variable", err,
Err: err, )
}
} }
var payload models.K8sIngressControllers var payload models.K8sIngressControllers
err = request.DecodeAndValidateJSONPayload(r, &payload) err = request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil { if err != nil {
return &httperror.HandlerError{ return httperror.BadRequest(
StatusCode: http.StatusBadRequest, "Invalid request payload",
Message: "Invalid request payload", err,
Err: err, )
}
} }
classes := endpoint.Kubernetes.Configuration.IngressClasses existingClasses := endpoint.Kubernetes.Configuration.IngressClasses
var updatedClasses []portainer.KubernetesIngressClassConfig
PayloadLoop: PayloadLoop:
for _, p := range payload { for _, p := range payload {
for i := range classes { for _, existingClass := range existingClasses {
if p.ClassName == classes[i].Name { if p.ClassName != existingClass.Name {
if p.Availability == true { updatedClasses = append(updatedClasses, existingClass)
classes[i].Blocked = false continue
classes[i].BlockedNamespaces = []string{} }
continue PayloadLoop var updatedClass portainer.KubernetesIngressClassConfig
} updatedClass.Name = existingClass.Name
updatedClass.Type = existingClass.Type
updatedClass.GloballyBlocked = existingClass.GloballyBlocked
// If it's meant to be blocked we need to add the current // Handle "allow"
// namespace. First, check if it's already in the if p.Availability == true {
// BlockedNamespaces and if not we append it. // remove the namespace from the list of blocked namespaces
classes[i].Blocked = true // in the existingClass.
for _, ns := range classes[i].BlockedNamespaces { for _, blockedNS := range existingClass.BlockedNamespaces {
if namespace == ns { if blockedNS != namespace {
continue PayloadLoop updatedClass.BlockedNamespaces = append(updatedClass.BlockedNamespaces, blockedNS)
} }
} }
classes[i].BlockedNamespaces = append(
classes[i].BlockedNamespaces, updatedClasses = append(updatedClasses, existingClass)
namespace, continue PayloadLoop
)
} }
// Handle "disallow"
// If it's meant to be blocked we need to add the current
// namespace. First, check if it's already in the
// BlockedNamespaces and if not we append it.
updatedClass.BlockedNamespaces = existingClass.BlockedNamespaces
for _, ns := range updatedClass.BlockedNamespaces {
if namespace == ns {
updatedClasses = append(updatedClasses, existingClass)
continue PayloadLoop
}
}
updatedClass.BlockedNamespaces = append(
updatedClass.BlockedNamespaces,
namespace,
)
updatedClasses = append(updatedClasses, updatedClass)
} }
} }
endpoint.Kubernetes.Configuration.IngressClasses = classes
fmt.Printf("%#v\n", endpoint.Kubernetes.Configuration.IngressClasses) endpoint.Kubernetes.Configuration.IngressClasses = updatedClasses
err = handler.dataStore.Endpoint().UpdateEndpoint( err = handler.dataStore.Endpoint().UpdateEndpoint(
portainer.EndpointID(endpointID), portainer.EndpointID(endpointID),
endpoint, endpoint,
) )
if err != nil { if err != nil {
return &httperror.HandlerError{ return httperror.InternalServerError(
StatusCode: http.StatusInternalServerError, "Unable to update the BlockedIngressClasses inside the database",
Message: "Unable to update the BlockedIngressClasses inside the database", err,
Err: err, )
}
} }
return nil return response.Empty(w)
} }
func (handler *Handler) getKubernetesIngresses(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { func (handler *Handler) getKubernetesIngresses(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
namespace, err := request.RetrieveRouteVariableValue(r, "namespace") namespace, err := request.RetrieveRouteVariableValue(r, "namespace")
if err != nil { if err != nil {
return &httperror.HandlerError{ return httperror.BadRequest(
StatusCode: http.StatusBadRequest, "Invalid namespace identifier route variable",
Message: "Invalid namespace identifier route variable", err,
Err: err, )
}
} }
cli := handler.KubernetesClient cli := handler.KubernetesClient
ingresses, err := cli.GetIngresses(namespace) ingresses, err := cli.GetIngresses(namespace)
if err != nil { if err != nil {
return &httperror.HandlerError{ return httperror.InternalServerError(
StatusCode: http.StatusInternalServerError, "Unable to retrieve nodes limits",
Message: "Unable to retrieve nodes limits", err,
Err: err, )
}
} }
return response.JSON(w, ingresses) return response.JSON(w, ingresses)
@ -328,33 +406,30 @@ func (handler *Handler) getKubernetesIngresses(w http.ResponseWriter, r *http.Re
func (handler *Handler) createKubernetesIngress(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { func (handler *Handler) createKubernetesIngress(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
namespace, err := request.RetrieveRouteVariableValue(r, "namespace") namespace, err := request.RetrieveRouteVariableValue(r, "namespace")
if err != nil { if err != nil {
return &httperror.HandlerError{ return httperror.BadRequest(
StatusCode: http.StatusBadRequest, "Invalid namespace identifier route variable",
Message: "Invalid namespace identifier route variable", err,
Err: err, )
}
} }
var payload models.K8sIngressInfo var payload models.K8sIngressInfo
err = request.DecodeAndValidateJSONPayload(r, &payload) err = request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil { if err != nil {
return &httperror.HandlerError{ return httperror.BadRequest(
StatusCode: http.StatusBadRequest, "Invalid request payload",
Message: "Invalid request payload", err,
Err: err, )
}
} }
cli := handler.KubernetesClient cli := handler.KubernetesClient
err = cli.CreateIngress(namespace, payload) err = cli.CreateIngress(namespace, payload)
if err != nil { if err != nil {
return &httperror.HandlerError{ return httperror.InternalServerError(
StatusCode: http.StatusInternalServerError, "Unable to retrieve nodes limits",
Message: "Unable to retrieve nodes limits", err,
Err: err, )
}
} }
return nil return response.Empty(w)
} }
func (handler *Handler) deleteKubernetesIngresses(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { func (handler *Handler) deleteKubernetesIngresses(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
@ -368,43 +443,39 @@ func (handler *Handler) deleteKubernetesIngresses(w http.ResponseWriter, r *http
err = cli.DeleteIngresses(payload) err = cli.DeleteIngresses(payload)
if err != nil { if err != nil {
return &httperror.HandlerError{ return httperror.InternalServerError(
StatusCode: http.StatusInternalServerError, "Unable to retrieve nodes limits",
Message: "Unable to retrieve nodes limits", err,
Err: err, )
}
} }
return nil return response.Empty(w)
} }
func (handler *Handler) updateKubernetesIngress(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { func (handler *Handler) updateKubernetesIngress(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
namespace, err := request.RetrieveRouteVariableValue(r, "namespace") namespace, err := request.RetrieveRouteVariableValue(r, "namespace")
if err != nil { if err != nil {
return &httperror.HandlerError{ return httperror.BadRequest(
StatusCode: http.StatusBadRequest, "Invalid namespace identifier route variable",
Message: "Invalid namespace identifier route variable", err,
Err: err, )
}
} }
var payload models.K8sIngressInfo var payload models.K8sIngressInfo
err = request.DecodeAndValidateJSONPayload(r, &payload) err = request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil { if err != nil {
return &httperror.HandlerError{ return httperror.BadRequest(
StatusCode: http.StatusBadRequest, "Invalid request payload",
Message: "Invalid request payload", err,
Err: err, )
}
} }
cli := handler.KubernetesClient cli := handler.KubernetesClient
err = cli.UpdateIngress(namespace, payload) err = cli.UpdateIngress(namespace, payload)
if err != nil { if err != nil {
return &httperror.HandlerError{ return httperror.InternalServerError(
StatusCode: http.StatusInternalServerError, "Unable to retrieve nodes limits",
Message: "Unable to retrieve nodes limits", err,
Err: err, )
}
} }
return nil return response.Empty(w)
} }

View File

@ -14,11 +14,10 @@ func (handler *Handler) getKubernetesNamespaces(w http.ResponseWriter, r *http.R
namespaces, err := cli.GetNamespaces() namespaces, err := cli.GetNamespaces()
if err != nil { if err != nil {
return &httperror.HandlerError{ return httperror.InternalServerError(
StatusCode: http.StatusInternalServerError, "Unable to retrieve nodes limits",
Message: "Unable to retrieve nodes limits", err,
Err: err, )
}
} }
return response.JSON(w, namespaces) return response.JSON(w, namespaces)
@ -27,23 +26,21 @@ func (handler *Handler) getKubernetesNamespaces(w http.ResponseWriter, r *http.R
func (handler *Handler) createKubernetesNamespace(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { func (handler *Handler) createKubernetesNamespace(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
cli := handler.KubernetesClient cli := handler.KubernetesClient
var payload models.K8sNamespaceInfo var payload models.K8sNamespaceDetails
err := request.DecodeAndValidateJSONPayload(r, &payload) err := request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil { if err != nil {
return &httperror.HandlerError{ return httperror.BadRequest(
StatusCode: http.StatusBadRequest, "Invalid request payload",
Message: "Invalid request payload", err,
Err: err, )
}
} }
err = cli.CreateNamespace(payload) err = cli.CreateNamespace(payload)
if err != nil { if err != nil {
return &httperror.HandlerError{ return httperror.InternalServerError(
StatusCode: http.StatusInternalServerError, "Unable to retrieve nodes limits",
Message: "Unable to retrieve nodes limits", err,
Err: err, )
}
} }
return nil return nil
} }
@ -53,20 +50,18 @@ func (handler *Handler) deleteKubernetesNamespaces(w http.ResponseWriter, r *htt
namespace, err := request.RetrieveRouteVariableValue(r, "namespace") namespace, err := request.RetrieveRouteVariableValue(r, "namespace")
if err != nil { if err != nil {
return &httperror.HandlerError{ return httperror.BadRequest(
StatusCode: http.StatusBadRequest, "Invalid namespace identifier route variable",
Message: "Invalid namespace identifier route variable", err,
Err: err, )
}
} }
err = cli.DeleteNamespace(namespace) err = cli.DeleteNamespace(namespace)
if err != nil { if err != nil {
return &httperror.HandlerError{ return httperror.InternalServerError(
StatusCode: http.StatusInternalServerError, "Unable to retrieve nodes limits",
Message: "Unable to retrieve nodes limits", err,
Err: err, )
}
} }
return nil return nil
@ -75,7 +70,7 @@ func (handler *Handler) deleteKubernetesNamespaces(w http.ResponseWriter, r *htt
func (handler *Handler) updateKubernetesNamespace(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { func (handler *Handler) updateKubernetesNamespace(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
cli := handler.KubernetesClient cli := handler.KubernetesClient
var payload models.K8sNamespaceInfo var payload models.K8sNamespaceDetails
err := request.DecodeAndValidateJSONPayload(r, &payload) err := request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil { if err != nil {
return &httperror.HandlerError{ return &httperror.HandlerError{

View File

@ -12,21 +12,19 @@ import (
func (handler *Handler) getKubernetesServices(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { func (handler *Handler) getKubernetesServices(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
namespace, err := request.RetrieveRouteVariableValue(r, "namespace") namespace, err := request.RetrieveRouteVariableValue(r, "namespace")
if err != nil { if err != nil {
return &httperror.HandlerError{ return httperror.BadRequest(
StatusCode: http.StatusBadRequest, "Invalid namespace identifier route variable",
Message: "Invalid namespace identifier route variable", err,
Err: err, )
}
} }
cli := handler.KubernetesClient cli := handler.KubernetesClient
services, err := cli.GetServices(namespace) services, err := cli.GetServices(namespace)
if err != nil { if err != nil {
return &httperror.HandlerError{ return httperror.InternalServerError(
StatusCode: http.StatusInternalServerError, "Unable to retrieve services",
Message: "Unable to retrieve services", err,
Err: err, )
}
} }
return response.JSON(w, services) return response.JSON(w, services)
@ -35,31 +33,28 @@ func (handler *Handler) getKubernetesServices(w http.ResponseWriter, r *http.Req
func (handler *Handler) createKubernetesService(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { func (handler *Handler) createKubernetesService(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
namespace, err := request.RetrieveRouteVariableValue(r, "namespace") namespace, err := request.RetrieveRouteVariableValue(r, "namespace")
if err != nil { if err != nil {
return &httperror.HandlerError{ return httperror.BadRequest(
StatusCode: http.StatusBadRequest, "Invalid namespace identifier route variable",
Message: "Invalid namespace identifier route variable", err,
Err: err, )
}
} }
var payload models.K8sServiceInfo var payload models.K8sServiceInfo
err = request.DecodeAndValidateJSONPayload(r, &payload) err = request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil { if err != nil {
return &httperror.HandlerError{ return httperror.BadRequest(
StatusCode: http.StatusBadRequest, "Invalid request payload",
Message: "Invalid request payload", err,
Err: err, )
}
} }
cli := handler.KubernetesClient cli := handler.KubernetesClient
err = cli.CreateService(namespace, payload) err = cli.CreateService(namespace, payload)
if err != nil { if err != nil {
return &httperror.HandlerError{ return httperror.InternalServerError(
StatusCode: http.StatusInternalServerError, "Unable to retrieve nodes limits",
Message: "Unable to retrieve nodes limits", err,
Err: err, )
}
} }
return nil return nil
} }
@ -70,20 +65,18 @@ func (handler *Handler) deleteKubernetesServices(w http.ResponseWriter, r *http.
var payload models.K8sServiceDeleteRequests var payload models.K8sServiceDeleteRequests
err := request.DecodeAndValidateJSONPayload(r, &payload) err := request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil { if err != nil {
return &httperror.HandlerError{ return httperror.BadRequest(
StatusCode: http.StatusBadRequest, "Invalid request payload",
Message: "Invalid request payload", err,
Err: err, )
}
} }
err = cli.DeleteServices(payload) err = cli.DeleteServices(payload)
if err != nil { if err != nil {
return &httperror.HandlerError{ return httperror.InternalServerError(
StatusCode: http.StatusInternalServerError, "Unable to retrieve nodes limits",
Message: "Unable to retrieve nodes limits", err,
Err: err, )
}
} }
return nil return nil
} }
@ -91,31 +84,28 @@ func (handler *Handler) deleteKubernetesServices(w http.ResponseWriter, r *http.
func (handler *Handler) updateKubernetesService(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { func (handler *Handler) updateKubernetesService(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
namespace, err := request.RetrieveRouteVariableValue(r, "namespace") namespace, err := request.RetrieveRouteVariableValue(r, "namespace")
if err != nil { if err != nil {
return &httperror.HandlerError{ return httperror.BadRequest(
StatusCode: http.StatusBadRequest, "Invalid namespace identifier route variable",
Message: "Invalid namespace identifier route variable", err,
Err: err, )
}
} }
var payload models.K8sServiceInfo var payload models.K8sServiceInfo
err = request.DecodeAndValidateJSONPayload(r, &payload) err = request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil { if err != nil {
return &httperror.HandlerError{ return httperror.BadRequest(
StatusCode: http.StatusBadRequest, "Invalid request payload",
Message: "Invalid request payload", err,
Err: err, )
}
} }
cli := handler.KubernetesClient cli := handler.KubernetesClient
err = cli.UpdateService(namespace, payload) err = cli.UpdateService(namespace, payload)
if err != nil { if err != nil {
return &httperror.HandlerError{ return httperror.InternalServerError(
StatusCode: http.StatusInternalServerError, "Unable to retrieve nodes limits",
Message: "Unable to retrieve nodes limits", err,
Err: err, )
}
} }
return nil return nil
} }

View File

@ -20,10 +20,34 @@ func (kcl *KubeClient) GetIngressControllers() models.K8sIngressControllers {
return nil return nil
} }
// We want to know which of these controllers is in use.
var ingresses []models.K8sIngressInfo
namespaces, err := kcl.GetNamespaces()
if err != nil {
return nil
}
for namespace := range namespaces {
t, err := kcl.GetIngresses(namespace)
if err != nil {
return nil
}
ingresses = append(ingresses, t...)
}
usedClasses := make(map[string]struct{})
for _, ingress := range ingresses {
usedClasses[ingress.ClassName] = struct{}{}
}
for _, class := range classList.Items { for _, class := range classList.Items {
var controller models.K8sIngressController var controller models.K8sIngressController
controller.Name = class.Spec.Controller controller.Name = class.Spec.Controller
controller.ClassName = class.Name controller.ClassName = class.Name
// If the class is used mark it as such.
if _, ok := usedClasses[class.Name]; ok {
controller.Used = true
}
switch { switch {
case strings.Contains(controller.Name, "nginx"): case strings.Contains(controller.Name, "nginx"):
controller.Type = "nginx" controller.Type = "nginx"

View File

@ -45,7 +45,7 @@ func (kcl *KubeClient) GetNamespaces() (map[string]portainer.K8sNamespaceInfo, e
} }
// CreateIngress creates a new ingress in a given namespace in a k8s endpoint. // CreateIngress creates a new ingress in a given namespace in a k8s endpoint.
func (kcl *KubeClient) CreateNamespace(info models.K8sNamespaceInfo) error { func (kcl *KubeClient) CreateNamespace(info models.K8sNamespaceDetails) error {
client := kcl.cli.CoreV1().Namespaces() client := kcl.cli.CoreV1().Namespaces()
var ns v1.Namespace var ns v1.Namespace
@ -108,7 +108,7 @@ func (kcl *KubeClient) ToggleSystemState(namespaceName string, isSystem bool) er
} }
// UpdateIngress updates an ingress in a given namespace in a k8s endpoint. // UpdateIngress updates an ingress in a given namespace in a k8s endpoint.
func (kcl *KubeClient) UpdateNamespace(info models.K8sNamespaceInfo) error { func (kcl *KubeClient) UpdateNamespace(info models.K8sNamespaceDetails) error {
client := kcl.cli.CoreV1().Namespaces() client := kcl.cli.CoreV1().Namespaces()
var ns v1.Namespace var ns v1.Namespace

View File

@ -546,13 +546,14 @@ type (
// KubernetesConfiguration represents the configuration of a Kubernetes environment(endpoint) // KubernetesConfiguration represents the configuration of a Kubernetes environment(endpoint)
KubernetesConfiguration struct { KubernetesConfiguration struct {
UseLoadBalancer bool `json:"UseLoadBalancer"` UseLoadBalancer bool `json:"UseLoadBalancer"`
UseServerMetrics bool `json:"UseServerMetrics"` UseServerMetrics bool `json:"UseServerMetrics"`
EnableResourceOverCommit bool `json:"EnableResourceOverCommit"` EnableResourceOverCommit bool `json:"EnableResourceOverCommit"`
ResourceOverCommitPercentage int `json:"ResourceOverCommitPercentage"` ResourceOverCommitPercentage int `json:"ResourceOverCommitPercentage"`
StorageClasses []KubernetesStorageClassConfig `json:"StorageClasses"` StorageClasses []KubernetesStorageClassConfig `json:"StorageClasses"`
IngressClasses []KubernetesIngressClassConfig `json:"IngressClasses"` IngressClasses []KubernetesIngressClassConfig `json:"IngressClasses"`
RestrictDefaultNamespace bool `json:"RestrictDefaultNamespace"` RestrictDefaultNamespace bool `json:"RestrictDefaultNamespace"`
IngressAvailabilityPerNamespace bool `json:"IngressAvailabilityPerNamespace"`
} }
// KubernetesStorageClassConfig represents a Kubernetes Storage Class configuration // KubernetesStorageClassConfig represents a Kubernetes Storage Class configuration
@ -567,7 +568,7 @@ type (
KubernetesIngressClassConfig struct { KubernetesIngressClassConfig struct {
Name string `json:"Name"` Name string `json:"Name"`
Type string `json:"Type"` Type string `json:"Type"`
Blocked bool `json:"Blocked"` GloballyBlocked bool `json:"Blocked"`
BlockedNamespaces []string `json:"BlockedNamespaces"` BlockedNamespaces []string `json:"BlockedNamespaces"`
} }
@ -1349,11 +1350,14 @@ type (
CreateUserShellPod(ctx context.Context, serviceAccountName, shellPodImage string) (*KubernetesShellPod, error) CreateUserShellPod(ctx context.Context, serviceAccountName, shellPodImage string) (*KubernetesShellPod, error)
StartExecProcess(token string, useAdminToken bool, namespace, podName, containerName string, command []string, stdin io.Reader, stdout io.Writer, errChan chan error) StartExecProcess(token string, useAdminToken bool, namespace, podName, containerName string, command []string, stdin io.Reader, stdout io.Writer, errChan chan error)
CreateNamespace(info models.K8sNamespaceInfo) error HasStackName(namespace string, stackName string) (bool, error)
UpdateNamespace(info models.K8sNamespaceInfo) error NamespaceAccessPoliciesDeleteNamespace(namespace string) error
CreateNamespace(info models.K8sNamespaceDetails) error
UpdateNamespace(info models.K8sNamespaceDetails) error
GetNamespaces() (map[string]K8sNamespaceInfo, error) GetNamespaces() (map[string]K8sNamespaceInfo, error)
DeleteNamespace(namespace string) error DeleteNamespace(namespace string) error
GetConfigMapsAndSecrets(namespace string) ([]models.K8sConfigMapOrSecret, error) GetConfigMapsAndSecrets(namespace string) ([]models.K8sConfigMapOrSecret, error)
GetIngressControllers() models.K8sIngressControllers
CreateIngress(namespace string, info models.K8sIngressInfo) error CreateIngress(namespace string, info models.K8sIngressInfo) error
UpdateIngress(namespace string, info models.K8sIngressInfo) error UpdateIngress(namespace string, info models.K8sIngressInfo) error
GetIngresses(namespace string) ([]models.K8sIngressInfo, error) GetIngresses(namespace string) ([]models.K8sIngressInfo, error)
@ -1362,10 +1366,6 @@ type (
UpdateService(namespace string, service models.K8sServiceInfo) error UpdateService(namespace string, service models.K8sServiceInfo) error
GetServices(namespace string) ([]models.K8sServiceInfo, error) GetServices(namespace string) ([]models.K8sServiceInfo, error)
DeleteServices(reqs models.K8sServiceDeleteRequests) error DeleteServices(reqs models.K8sServiceDeleteRequests) error
GetIngressControllers() models.K8sIngressControllers
HasStackName(namespace string, stackName string) (bool, error)
NamespaceAccessPoliciesDeleteNamespace(namespace string) error
GetNodesLimits() (K8sNodesLimits, error) GetNodesLimits() (K8sNodesLimits, error)
GetNamespaceAccessPolicies() (map[string]K8sNamespaceAccessPolicy, error) GetNamespaceAccessPolicies() (map[string]K8sNamespaceAccessPolicy, error)
UpdateNamespaceAccessPolicies(accessPolicies map[string]K8sNamespaceAccessPolicy) error UpdateNamespaceAccessPolicies(accessPolicies map[string]K8sNamespaceAccessPolicy) error

View File

@ -1,6 +1,7 @@
import angular from 'angular'; import angular from 'angular';
import { r2a } from '@/react-tools/react2angular'; import { r2a } from '@/react-tools/react2angular';
import { IngressClassDatatable } from '@/react/kubernetes/cluster/ingressClass/IngressClassDatatable';
import { NamespacesSelector } from '@/react/kubernetes/cluster/RegistryAccessView/NamespacesSelector'; import { NamespacesSelector } from '@/react/kubernetes/cluster/RegistryAccessView/NamespacesSelector';
import { StorageAccessModeSelector } from '@/react/kubernetes/cluster/ConfigureView/StorageAccessModeSelector'; import { StorageAccessModeSelector } from '@/react/kubernetes/cluster/ConfigureView/StorageAccessModeSelector';
import { NamespaceAccessUsersSelector } from '@/react/kubernetes/namespaces/AccessView/NamespaceAccessUsersSelector'; import { NamespaceAccessUsersSelector } from '@/react/kubernetes/namespaces/AccessView/NamespaceAccessUsersSelector';
@ -8,6 +9,16 @@ import { CreateNamespaceRegistriesSelector } from '@/react/kubernetes/namespaces
export const componentsModule = angular export const componentsModule = angular
.module('portainer.kubernetes.react.components', []) .module('portainer.kubernetes.react.components', [])
.component(
'ingressClassDatatable',
r2a(IngressClassDatatable, [
'onChangeAvailability',
'description',
'ingressControllers',
'noIngressControllerLabel',
'view',
])
)
.component( .component(
'namespacesSelector', 'namespacesSelector',
r2a(NamespacesSelector, [ r2a(NamespacesSelector, [

View File

@ -158,10 +158,12 @@ export function CreateIngressView() {
); );
const ingressClassOptions: Option<string>[] = [ const ingressClassOptions: Option<string>[] = [
{ label: 'Select an ingress class', value: '' }, { label: 'Select an ingress class', value: '' },
...(ingressControllersResults.data?.map((cls) => ({ ...(ingressControllersResults.data
label: cls.ClassName, ?.filter((cls) => cls.Availability)
value: cls.ClassName, .map((cls) => ({
})) || []), label: cls.ClassName,
value: cls.ClassName,
})) || []),
]; ];
if (!existingIngressClass && ingressRule.IngressClassName) { if (!existingIngressClass && ingressRule.IngressClassName) {

View File

@ -65,14 +65,12 @@ export function useIngresses(
'ingress', 'ingress',
], ],
async () => { async () => {
const ingresses: Ingress[] = []; const ingresses = await Promise.all(
for (let i = 0; i < namespaces.length; i += 1) { namespaces.map((namespace) => getIngresses(environmentId, namespace))
const ings = await getIngresses(environmentId, namespaces[i]); );
if (ings) { // flatten the array and remove empty ingresses
ingresses.push(...ings); const filteredIngresses = ingresses.flat().filter((ing) => ing);
} return filteredIngresses;
}
return ingresses;
}, },
{ {
enabled: namespaces.length > 0, enabled: namespaces.length > 0,

View File

@ -35,124 +35,35 @@
<div class="col-sm-12"> <div class="col-sm-12">
<label class="control-label text-left col-sm-5 col-lg-4 px-0"> Allow users to use external load balancer </label> <label class="control-label text-left col-sm-5 col-lg-4 px-0"> Allow users to use external load balancer </label>
<label class="switch mb-0 col-sm-8"> <label class="switch col-sm-8 mb-0">
<input type="checkbox" ng-model="ctrl.formValues.UseLoadBalancer" /><span class="slider round" data-cy="kubeSetup-loadBalancerToggle"></span> <input type="checkbox" ng-model="ctrl.formValues.UseLoadBalancer" /><span class="slider round" data-cy="kubeSetup-loadBalancerToggle"></span>
</label> </label>
</div> </div>
</div> </div>
<div class="form-group"> <ingress-class-datatable
<div class="col-sm-12 text-muted small mt-4"> on-change-availability="(ctrl.onChangeAvailability)"
<p> Configuring ingress controllers will allow users to expose application they deploy over a HTTP route. </p> ingress-controllers="ctrl.originalIngressControllers"
<p class="mt-1 vertical-center"> description="'Enabling ingress controllers in your cluster allows them to be available in the Portainer UI for users to publish applications over HTTP/HTTPS. A controller must have a class name for it to be included here.'"
<pr-icon icon="'alert-circle'" mode="'warning'" feather="true"></pr-icon> no-ingress-controller-label="'No supported ingress controllers found.'"
Ingress classes must be manually specified for each controller you want to use in the cluster. Make sure that each controller is running inside your cluster. view="'cluster'"
</p> ></ingress-class-datatable>
</div>
</div>
<div class="form-group"> <div class="form-group">
<div class="col-sm-12"> <div class="col-sm-12">
<label class="control-label text-left">Ingress controller</label> <por-switch-field
<span class="label label-default interactive vertical-center" style="margin-left: 10px" ng-click="ctrl.addIngressClass()" data-cy="kubeSetup-congifIngressButton"> checked="ctrl.formValues.IngressAvailabilityPerNamespace"
<pr-icon icon="'plus'" feather="true" size="'sm'" class="vertical-center"></pr-icon> configure ingress controller name="'ingressAvailabilityPerNamespace'"
</span> label="'Configure ingress controller availability per namespace'"
</div> tooltip="'This allows an administrator to configure, in each namespace, which ingress controllers will be available for users to select when setting up ingresses for applications.'"
on-change="(ctrl.onToggleIngressAvailabilityPerNamespace)"
<div class="col-sm-12 form-inline" style="margin-top: 10px"> label-class="'col-sm-5 col-lg-4 px-0 !m-0'"
<div ng-repeat-start="ingressClass in ctrl.formValues.IngressClasses" style="margin-top: 2px"> switch-class="'col-sm-8'"
<div class="col-sm-7 input-group input-group-sm" ng-class="{ striked: ingressClass.NeedsDeletion }">
<span class="input-group-addon">Ingress class</span>
<input
type="text"
class="form-control"
name="ingress_class_name_{{ $index }}"
ng-model="ingressClass.Name"
placeholder="nginx"
ng-pattern="/^[a-z]([-a-z0-9]*[a-z0-9])?$/"
ng-change="ctrl.onChangeIngressClassName($index)"
required
data-cy="kubeSetup-ingressClassName"
/>
</div>
<div class="col-sm-3 input-group input-group-sm" ng-class="{ striked: ingressClass.NeedsDeletion }">
<span class="input-group-addon">Type</span>
<select
class="form-control"
name="ingress_class_type_{{ $index }}"
ng-model="ingressClass.Type"
ng-options="value as value for (key, value) in ctrl.IngressClassTypes"
required
data-cy="kubeSetup-ingressType"
>
<option selected disabled hidden value="">Select a type</option>
</select>
</div>
<div class="col-sm-1 input-group input-group-sm">
<button
ng-if="!ingressClass.NeedsDeletion"
class="btn btn-dangerlight btn-only-icon"
type="button"
ng-click="ctrl.removeIngressClass($index)"
data-cy="kubeSetup-deleteIngress"
>
<pr-icon icon="'trash-2'" feather="true"></pr-icon>
</button>
<button ng-if="ingressClass.NeedsDeletion" class="btn btn-only-icon btn-light" type="button" ng-click="ctrl.restoreIngressClass($index)">
<pr-icon icon="'rotate-ccw'" feather="true"></pr-icon>
</button>
</div>
</div>
<div
ng-repeat-end
ng-show="
kubernetesClusterSetupForm['ingress_class_name_' + $index].$invalid ||
kubernetesClusterSetupForm['ingress_class_type_' + $index].$invalid ||
ctrl.state.duplicates.ingressClasses.refs[$index] !== undefined
"
> >
<div class="col-sm-7 input-group"> </por-switch-field>
<div
class="small"
style="margin-top: 5px"
ng-if="kubernetesClusterSetupForm['ingress_class_name_' + $index].$invalid || ctrl.state.duplicates.ingressClasses.refs[$index] !== undefined"
>
<div ng-messages="kubernetesClusterSetupForm['ingress_class_name_'+$index].$error">
<p ng-message="required" class="vertical-center text-warning"
><pr-icon icon="'alert-triangle'" mode="'warning'" feather="true"></pr-icon> Ingress class name is required.</p
>
<p ng-message="pattern" class="vertical-center text-warning"
><pr-icon icon="'alert-triangle'" mode="'warning'" feather="true"></pr-icon> This field must consist of lower case alphanumeric characters or '-', start
with an alphabetic character, and end with an alphanumeric character (e.g. 'my-name', or 'abc-123').</p
>
</div>
<p ng-if="ctrl.state.duplicates.ingressClasses.refs[$index] !== undefined" class="vertical-center text-warning">
<pr-icon icon="'alert-triangle'" mode="'warning'" feather="true"></pr-icon> This ingress class is already defined.
</p>
</div>
</div>
<div class="col-sm-3 input-group">
<div class="small" style="margin-top: 5px" ng-if="kubernetesClusterSetupForm['ingress_class_type_' + $index].$invalid">
<div ng-messages="kubernetesClusterSetupForm['ingress_class_type_'+$index].$error">
<p ng-message="required" class="vertical-center text-warning"
><pr-icon icon="'alert-triangle'" mode="'warning'" feather="true"></pr-icon> Ingress class type is required.</p
>
</div>
</div>
</div>
</div>
</div> </div>
</div> </div>
<div class="form-group" ng-if="ctrl.hasTraefikIngress()">
<span class="col-sm-12 text-muted small">
<p>
<i class="fa fa-flask blue-icon" aria-hidden="true" style="margin-right: 2px"></i>
Traefik support is experimental.
</p>
</span>
</div>
<!-- auto update window --> <!-- auto update window -->
<div class="col-sm-12 form-section-title"> Change Window Settings </div> <div class="col-sm-12 form-section-title"> Change Window Settings </div>
@ -163,7 +74,7 @@
name="'disableSysctlSettingForRegularUsers'" name="'disableSysctlSettingForRegularUsers'"
label="'Enable Change Window'" label="'Enable Change Window'"
feature-id="ctrl.limitedFeatureAutoWindow" feature-id="ctrl.limitedFeatureAutoWindow"
tooltip="'Specify a timeframe during which automatic updates can occur in this environment.'" tooltip="'Automatic updates to stacks or applications outside the defined change window will not occur.'"
on-change="(ctrl.onToggleAutoUpdate)" on-change="(ctrl.onToggleAutoUpdate)"
label-class="'col-sm-5 col-lg-4 px-0 !m-0'" label-class="'col-sm-5 col-lg-4 px-0 !m-0'"
switch-class="'col-sm-8 text-muted'" switch-class="'col-sm-8 text-muted'"

View File

@ -2,12 +2,12 @@ import _ from 'lodash-es';
import angular from 'angular'; import angular from 'angular';
import { KubernetesStorageClass, KubernetesStorageClassAccessPolicies } from 'Kubernetes/models/storage-class/models'; import { KubernetesStorageClass, KubernetesStorageClassAccessPolicies } from 'Kubernetes/models/storage-class/models';
import { KubernetesFormValidationReferences } from 'Kubernetes/models/application/formValues'; import { KubernetesFormValidationReferences } from 'Kubernetes/models/application/formValues';
import { KubernetesIngressClass } from 'Kubernetes/ingress/models';
import KubernetesFormValidationHelper from 'Kubernetes/helpers/formValidationHelper';
import { KubernetesIngressClassTypes } from 'Kubernetes/ingress/constants'; import { KubernetesIngressClassTypes } from 'Kubernetes/ingress/constants';
import KubernetesNamespaceHelper from 'Kubernetes/helpers/namespaceHelper'; import KubernetesNamespaceHelper from 'Kubernetes/helpers/namespaceHelper';
import { FeatureId } from '@/portainer/feature-flags/enums'; import { FeatureId } from '@/portainer/feature-flags/enums';
import { getIngressControllerClassMap, updateIngressControllerClassMap } from '@/react/kubernetes/cluster/ingressClass/utils';
class KubernetesConfigureController { class KubernetesConfigureController {
/* #region CONSTRUCTOR */ /* #region CONSTRUCTOR */
@ -41,10 +41,15 @@ class KubernetesConfigureController {
this.onInit = this.onInit.bind(this); this.onInit = this.onInit.bind(this);
this.configureAsync = this.configureAsync.bind(this); this.configureAsync = this.configureAsync.bind(this);
this.areControllersChanged = this.areControllersChanged.bind(this);
this.areFormValuesChanged = this.areFormValuesChanged.bind(this);
this.onBeforeOnload = this.onBeforeOnload.bind(this);
this.limitedFeature = FeatureId.K8S_SETUP_DEFAULT; this.limitedFeature = FeatureId.K8S_SETUP_DEFAULT;
this.limitedFeatureAutoWindow = FeatureId.HIDE_AUTO_UPDATE_WINDOW; this.limitedFeatureAutoWindow = FeatureId.HIDE_AUTO_UPDATE_WINDOW;
this.onToggleAutoUpdate = this.onToggleAutoUpdate.bind(this); this.onToggleAutoUpdate = this.onToggleAutoUpdate.bind(this);
this.onChangeAvailability = this.onChangeAvailability.bind(this);
this.onChangeEnableResourceOverCommit = this.onChangeEnableResourceOverCommit.bind(this); this.onChangeEnableResourceOverCommit = this.onChangeEnableResourceOverCommit.bind(this);
this.onToggleIngressAvailabilityPerNamespace = this.onToggleIngressAvailabilityPerNamespace.bind(this);
this.onChangeStorageClassAccessMode = this.onChangeStorageClassAccessMode.bind(this); this.onChangeStorageClassAccessMode = this.onChangeStorageClassAccessMode.bind(this);
} }
/* #endregion */ /* #endregion */
@ -66,48 +71,23 @@ class KubernetesConfigureController {
/* #endregion */ /* #endregion */
/* #region INGRESS CLASSES UI MANAGEMENT */ /* #region INGRESS CLASSES UI MANAGEMENT */
addIngressClass() { onChangeAvailability(controllerClassMap) {
this.formValues.IngressClasses.push(new KubernetesIngressClass()); this.ingressControllers = controllerClassMap;
this.onChangeIngressClass();
}
restoreIngressClass(index) {
this.formValues.IngressClasses[index].NeedsDeletion = false;
this.onChangeIngressClass();
}
removeIngressClass(index) {
if (!this.formValues.IngressClasses[index].IsNew) {
this.formValues.IngressClasses[index].NeedsDeletion = true;
} else {
this.formValues.IngressClasses.splice(index, 1);
}
this.onChangeIngressClass();
}
onChangeIngressClass() {
const state = this.state.duplicates.ingressClasses;
const source = _.map(this.formValues.IngressClasses, (ic) => (ic.NeedsDeletion ? undefined : ic.Name));
const duplicates = KubernetesFormValidationHelper.getDuplicates(source);
state.refs = duplicates;
state.hasRefs = Object.keys(duplicates).length > 0;
}
onChangeIngressClassName(index) {
const fv = this.formValues.IngressClasses[index];
if (_.includes(fv.Name, KubernetesIngressClassTypes.NGINX)) {
fv.Type = KubernetesIngressClassTypes.NGINX;
} else if (_.includes(fv.Name, KubernetesIngressClassTypes.TRAEFIK)) {
fv.Type = KubernetesIngressClassTypes.TRAEFIK;
}
this.onChangeIngressClass();
} }
hasTraefikIngress() { hasTraefikIngress() {
return _.find(this.formValues.IngressClasses, { Type: this.IngressClassTypes.TRAEFIK }); return _.find(this.formValues.IngressClasses, { Type: this.IngressClassTypes.TRAEFIK });
} }
onToggleIngressAvailabilityPerNamespace() {
this.$scope.$evalAsync(() => {
this.formValues.IngressAvailabilityPerNamespace = !this.formValues.IngressAvailabilityPerNamespace;
});
}
/* #endregion */ /* #endregion */
/* #region RESOURCES AND METRICS */
onChangeEnableResourceOverCommit(enabled) { onChangeEnableResourceOverCommit(enabled) {
this.$scope.$evalAsync(() => { this.$scope.$evalAsync(() => {
this.formValues.EnableResourceOverCommit = enabled; this.formValues.EnableResourceOverCommit = enabled;
@ -117,13 +97,19 @@ class KubernetesConfigureController {
}); });
} }
/* #endregion */
/* #region CONFIGURE */ /* #region CONFIGURE */
assignFormValuesToEndpoint(endpoint, storageClasses, ingressClasses) { assignFormValuesToEndpoint(endpoint, storageClasses, ingressClasses) {
endpoint.Kubernetes.Configuration.StorageClasses = storageClasses; endpoint.Kubernetes.Configuration.StorageClasses = storageClasses;
endpoint.Kubernetes.Configuration.UseLoadBalancer = this.formValues.UseLoadBalancer; endpoint.Kubernetes.Configuration.UseLoadBalancer = this.formValues.UseLoadBalancer;
endpoint.Kubernetes.Configuration.UseServerMetrics = this.formValues.UseServerMetrics; endpoint.Kubernetes.Configuration.UseServerMetrics = this.formValues.UseServerMetrics;
endpoint.Kubernetes.Configuration.EnableResourceOverCommit = this.formValues.EnableResourceOverCommit;
endpoint.Kubernetes.Configuration.ResourceOverCommitPercentage = this.formValues.ResourceOverCommitPercentage;
endpoint.Kubernetes.Configuration.IngressClasses = ingressClasses; endpoint.Kubernetes.Configuration.IngressClasses = ingressClasses;
endpoint.Kubernetes.Configuration.RestrictDefaultNamespace = this.formValues.RestrictDefaultNamespace; endpoint.Kubernetes.Configuration.RestrictDefaultNamespace = this.formValues.RestrictDefaultNamespace;
endpoint.Kubernetes.Configuration.IngressAvailabilityPerNamespace = this.formValues.IngressAvailabilityPerNamespace;
endpoint.ChangeWindow = this.state.autoUpdateSettings;
} }
transformFormValues() { transformFormValues() {
@ -150,11 +136,9 @@ class KubernetesConfigureController {
async removeIngressesAcrossNamespaces() { async removeIngressesAcrossNamespaces() {
const ingressesToDel = _.filter(this.formValues.IngressClasses, { NeedsDeletion: true }); const ingressesToDel = _.filter(this.formValues.IngressClasses, { NeedsDeletion: true });
if (!ingressesToDel.length) { if (!ingressesToDel.length) {
return; return;
} }
const promises = []; const promises = [];
const oldEndpointID = this.EndpointProvider.endpointID(); const oldEndpointID = this.EndpointProvider.endpointID();
this.EndpointProvider.setEndpointID(this.endpoint.Id); this.EndpointProvider.setEndpointID(this.endpoint.Id);
@ -213,7 +197,9 @@ class KubernetesConfigureController {
this.assignFormValuesToEndpoint(this.endpoint, storageClasses, ingressClasses); this.assignFormValuesToEndpoint(this.endpoint, storageClasses, ingressClasses);
await this.EndpointService.updateEndpoint(this.endpoint.Id, this.endpoint); await this.EndpointService.updateEndpoint(this.endpoint.Id, this.endpoint);
// updateIngressControllerClassMap must be done after updateEndpoint, as a hacky workaround. A better solution: saving ingresscontrollers somewhere else, is being discussed
await updateIngressControllerClassMap(this.state.endpointId, this.ingressControllers);
this.state.isSaving = true;
const storagePromises = _.map(storageClasses, (storageClass) => { const storagePromises = _.map(storageClasses, (storageClass) => {
const oldStorageClass = _.find(this.oldStorageClasses, { Name: storageClass.Name }); const oldStorageClass = _.find(this.oldStorageClasses, { Name: storageClass.Name });
if (oldStorageClass) { if (oldStorageClass) {
@ -291,19 +277,31 @@ class KubernetesConfigureController {
isServerRunning: false, isServerRunning: false,
userClick: false, userClick: false,
}, },
timeZone: '',
isSaving: false,
}; };
this.formValues = { this.formValues = {
UseLoadBalancer: false, UseLoadBalancer: false,
UseServerMetrics: false, UseServerMetrics: false,
EnableResourceOverCommit: true,
ResourceOverCommitPercentage: 20,
IngressClasses: [], IngressClasses: [],
RestrictDefaultNamespace: false, RestrictDefaultNamespace: false,
enableAutoUpdateTimeWindow: false,
IngressAvailabilityPerNamespace: false,
}; };
try { try {
this.availableAccessModes = new KubernetesStorageClassAccessPolicies(); this.availableAccessModes = new KubernetesStorageClassAccessPolicies();
[this.StorageClasses, this.endpoint] = await Promise.all([this.KubernetesStorageService.get(this.state.endpointId), this.EndpointService.endpoint(this.state.endpointId)]); [this.StorageClasses, this.endpoint] = await Promise.all([this.KubernetesStorageService.get(this.state.endpointId), this.EndpointService.endpoint(this.state.endpointId)]);
this.ingressControllers = await getIngressControllerClassMap({ environmentId: this.state.endpointId });
this.originalIngressControllers = structuredClone(this.ingressControllers);
this.state.autoUpdateSettings = this.endpoint.ChangeWindow;
_.forEach(this.StorageClasses, (item) => { _.forEach(this.StorageClasses, (item) => {
const storage = _.find(this.endpoint.Kubernetes.Configuration.StorageClasses, (sc) => sc.Name === item.Name); const storage = _.find(this.endpoint.Kubernetes.Configuration.StorageClasses, (sc) => sc.Name === item.Name);
if (storage) { if (storage) {
@ -316,12 +314,15 @@ class KubernetesConfigureController {
this.formValues.UseLoadBalancer = this.endpoint.Kubernetes.Configuration.UseLoadBalancer; this.formValues.UseLoadBalancer = this.endpoint.Kubernetes.Configuration.UseLoadBalancer;
this.formValues.UseServerMetrics = this.endpoint.Kubernetes.Configuration.UseServerMetrics; this.formValues.UseServerMetrics = this.endpoint.Kubernetes.Configuration.UseServerMetrics;
this.formValues.EnableResourceOverCommit = this.endpoint.Kubernetes.Configuration.EnableResourceOverCommit;
this.formValues.ResourceOverCommitPercentage = this.endpoint.Kubernetes.Configuration.ResourceOverCommitPercentage;
this.formValues.RestrictDefaultNamespace = this.endpoint.Kubernetes.Configuration.RestrictDefaultNamespace; this.formValues.RestrictDefaultNamespace = this.endpoint.Kubernetes.Configuration.RestrictDefaultNamespace;
this.formValues.IngressClasses = _.map(this.endpoint.Kubernetes.Configuration.IngressClasses, (ic) => { this.formValues.IngressClasses = _.map(this.endpoint.Kubernetes.Configuration.IngressClasses, (ic) => {
ic.IsNew = false; ic.IsNew = false;
ic.NeedsDeletion = false; ic.NeedsDeletion = false;
return ic; return ic;
}); });
this.formValues.IngressAvailabilityPerNamespace = this.endpoint.Kubernetes.Configuration.IngressAvailabilityPerNamespace;
this.oldFormValues = Object.assign({}, this.formValues); this.oldFormValues = Object.assign({}, this.formValues);
} catch (err) { } catch (err) {
@ -329,12 +330,48 @@ class KubernetesConfigureController {
} finally { } finally {
this.state.viewReady = true; this.state.viewReady = true;
} }
window.addEventListener('beforeunload', this.onBeforeOnload);
} }
$onInit() { $onInit() {
return this.$async(this.onInit); return this.$async(this.onInit);
} }
/* #endregion */ /* #endregion */
$onDestroy() {
window.removeEventListener('beforeunload', this.onBeforeOnload);
}
areControllersChanged() {
return !_.isEqual(this.ingressControllers, this.originalIngressControllers);
}
areFormValuesChanged() {
return !_.isEqual(this.formValues, this.oldFormValues);
}
onBeforeOnload(event) {
if (!this.state.isSaving && (this.areControllersChanged() || this.areFormValuesChanged())) {
event.preventDefault();
event.returnValue = '';
}
}
uiCanExit() {
if (!this.state.isSaving && (this.areControllersChanged() || this.areFormValuesChanged())) {
return this.ModalService.confirmAsync({
title: 'Are you sure?',
message: 'You currently have unsaved changes in the cluster setup view. Are you sure you want to leave?',
buttons: {
confirm: {
label: 'Yes',
className: 'btn-danger',
},
},
});
}
}
} }
export default KubernetesConfigureController; export default KubernetesConfigureController;

View File

@ -180,214 +180,17 @@
</div> </div>
<!-- #endregion --> <!-- #endregion -->
<!-- #region STORAGES --> <div ng-if="$ctrl.state.ingressAvailabilityPerNamespace">
<div class="col-sm-12 form-section-title"> Storage </div>
<div class="form-group">
<span class="col-sm-12 text-muted small vertical-center">
<pr-icon icon="'info'" mode="'primary'" feather="true"></pr-icon>
Quotas can be set on each storage option to prevent users from exceeding a specific threshold when deploying applications. You can set a quota to 0 to effectively
prevent the usage of a specific storage option inside this namespace.
</span>
</div>
<div class="col-sm-12 form-section-title vertical-center">
<pr-icon icon="'svg-route'"></pr-icon>
standard
</div>
<storage-class-switch value="sc.Selected" name="sc.Name" on-change="(ctrl.onToggleStorageQuota)" authorization="K8sResourcePoolDetailsW"></storage-class-switch>
<!-- #endregion -->
<div ng-if="$ctrl.state.canUseIngress">
<div class="col-sm-12 form-section-title"> Ingresses </div>
<!-- #region INGRESSES --> <!-- #region INGRESSES -->
<div class="form-group" ng-if="$ctrl.formValues.IngressClasses.length === 0"> <div class="col-sm-12 form-section-title"> Networking </div>
<div class="col-sm-12 small text-muted"> <ingress-class-datatable
The ingress feature must be enabled in the ng-if="$ctrl.state.ingressAvailabilityPerNamespace"
<a ui-sref="kubernetes.cluster.setup">environment configuration view</a> to be able to register ingresses inside this namespace. on-change-availability="($ctrl.onChangeIngressControllerAvailability)"
</div> ingress-controllers="$ctrl.ingressControllers"
</div> description="'Enable the ingress controllers that users can select when publishing applications in this namespace.'"
no-ingress-controller-label="'No ingress controllers found in the cluster. Go to the cluster setup view to configure and allow the use of ingress controllers in the cluster.'"
<div class="form-group" ng-if="$ctrl.formValues.IngressClasses.length > 0"> view="'namespace'"
<div class="col-sm-12 small text-muted"> ></ingress-class-datatable>
<p class="vertical-center">
<pr-icon icon="'info'" mode="'primary'" feather="true"></pr-icon>
Enable and configure ingresses available to users when deploying applications.
</p>
</div>
</div>
<div class="form-group" ng-repeat-start="ic in $ctrl.formValues.IngressClasses track by ic.IngressClass.Name">
<div class="row">
<div class="col-sm-12">
<span class="text-muted vertical-center"><pr-icon icon="'svg-route'"></pr-icon> {{ ic.IngressClass.Name }}</span>
<hr class="mt-2 mb-0" />
</div>
</div>
<div class="row">
<div class="col-sm-3 col-lg-2">
<label class="control-label text-left"> Allow users to use this ingress </label>
</div>
<div class="col-sm-8 pt-2">
<label class="switch">
<input type="checkbox" ng-model="ic.Selected" /><span class="slider round" data-cy="namespaceCreate-ingressToggle{{ ic.IngressClass.Name }}"></span>
</label>
</div>
</div>
</div>
<div ng-if="ic.Selected">
<div class="form-group">
<div class="col-sm-12">
<label class="control-label text-left">
Hostnames
<portainer-tooltip
message="'Hostnames associated to the ingress inside this namespace. Users will be able to expose and access their applications over the ingress via one of these hostname.'"
>
</portainer-tooltip>
</label>
<span class="label label-default interactive" ng-click="$ctrl.addHostname(ic)" data-cy="namespaceCreate-addHostButton{{ ic.IngressClass.Name }}">
<pr-icon icon="'plus'" feather="true"></pr-icon> add hostname
</span>
</div>
<div class="col-sm-12 pt-4">
<div ng-repeat="item in ic.Hosts track by $index">
<div class="form-inline">
<div class="col-sm-8 input-group input-group-sm pt-2">
<span class="input-group-addon required">Hostname</span>
<input
type="text"
class="form-control"
name="hostname_{{ ic.IngressClass.Name }}_{{ $index }}"
ng-model="item.Host"
ng-change="$ctrl.onChangeIngressHostname()"
placeholder="foo"
pattern="[\*a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*"
required
data-cy="namespaceCreate-hostnameInput{{ ic.IngressClass.Name }}_{{ $index }}"
/>
</div>
<div class="col-sm-1 input-group input-group-sm !pt-2" ng-if="$index > 0">
<button class="btn btn-md btn-dangerlight btn-only-icon" type="button" ng-click="$ctrl.removeHostname(ic, $index)">
<pr-icon icon="'trash-2'" size="'md'" feather="true"></pr-icon>
</button>
</div>
</div>
<div
class="small text-warning pt-1"
ng-show="
resourcePoolCreationForm['hostname_' + ic.IngressClass.Name + '_' + $index].$invalid ||
$ctrl.state.duplicates.ingressHosts.refs[ic.IngressClass.Name][$index] !== undefined
"
>
<ng-messages for="resourcePoolCreationForm['hostname_' + ic.IngressClass.Name + '_' + $index].$error">
<p class="vertical-center" ng-message="required"><pr-icon icon="'alert-triangle'" feather="true" mode="'warning'"></pr-icon> Hostname is required.</p>
<p class="vertical-center" ng-message="pattern">
<pr-icon icon="'alert-triangle'" feather="true" mode="'warning'"></pr-icon>
This field must consist of lower case alphanumeric characters, '-' or '.', and must start and end with an alphanumeric character (e.g. 'example.com').
</p>
</ng-messages>
<p class="vertical-center" ng-if="$ctrl.state.duplicates.ingressHosts.refs[ic.IngressClass.Name][$index] !== undefined">
<pr-icon icon="'alert-triangle'" feather="true" mode="'warning'"></pr-icon> This hostname is already used.
</p>
</div>
</div>
</div>
</div>
</div>
<div ng-repeat-end class="form-group" ng-if="ic.Selected">
<div class="col-sm-12 small text-muted">
<p class="vertical-center">
<pr-icon icon="'info'" mode="'primary'" feather="true"></pr-icon>
You can specify a list of annotations that will be associated to the ingress.
</p>
</div>
<div class="col-sm-12">
<label class="control-label text-left">Annotations </label>
<label class="control-label text-left">
<span class="label label-default interactive" ng-click="$ctrl.addAnnotation(ic)" data-cy="namespaceCreate-addAnnotation{{ ic.IngressClass.Name }}">
<pr-icon icon="'plus'" feather="true"></pr-icon> add annotation
</span>
<portainer-tooltip
message="'Use annotations to configure options for an ingress. Review Nginx or Traefik documentation to find the annotations supported by your choice of ingress type.'"
>
</portainer-tooltip>
</label>
<label class="control-label text-left">
<span
class="label label-default interactive"
ng-if="ic.IngressClass.Type === $ctrl.IngressClassTypes.NGINX"
ng-click="$ctrl.addRewriteAnnotation(ic)"
data-cy="namespaceCreate-addAnnotation{{ ic.IngressClass.Name }}"
>
<pr-icon icon="'plus'" feather="true"></pr-icon> add rewrite annotation
</span>
<portainer-tooltip
ng-if="ic.IngressClass.Type === $ctrl.IngressClassTypes.NGINX"
message="'When the exposed URLs for your applications differ from the specified paths in the ingress, use the rewrite target annotation to denote the path to redirect to.'"
>
</portainer-tooltip>
</label>
<label class="control-label text-left">
<span
class="label label-default interactive"
ng-if="ic.IngressClass.Type === $ctrl.IngressClassTypes.NGINX"
ng-click="$ctrl.addUseregexAnnotation(ic)"
data-cy="namespaceCreate-addAnnotation{{ ic.IngressClass.Name }}"
>
<pr-icon icon="'plus'" feather="true"></pr-icon> add regular expression annotation
</span>
<portainer-tooltip
ng-if="ic.IngressClass.Type === $ctrl.IngressClassTypes.NGINX"
message="'Enable use of regular expressions in ingress paths (set in the ingress details of an application). Use this along with rewrite-target to specify the regex capturing group to be replaced, e.g. path regex of ^/foo/(,*) and rewrite-target of /bar/$1 rewrites example.com/foo/account to example.com/bar/account.'"
>
</portainer-tooltip>
</label>
</div>
<div class="col-sm-12 form-inline pt-4">
<div class="pt-2" ng-repeat="annotation in ic.Annotations track by $index">
<div class="input-group col-sm-5 input-group-sm">
<span class="input-group-addon">Key</span>
<input
type="text"
class="form-control"
ng-model="annotation.Key"
placeholder="{{
ic.IngressClass.Type === $ctrl.IngressClassTypes.NGINX
? 'e.g. nginx.ingress.kubernetes.io/enable-rewrite-log'
: 'e.g. traefik.ingress.kubernetes.io/router.priority'
}}"
required
data-cy="namespaceCreate-annotationKey{{ ic.IngressClass.Name }}"
/>
</div>
<div class="input-group input-group-sm col-sm-5">
<span class="input-group-addon">Value</span>
<input
type="text"
class="form-control"
ng-model="annotation.Value"
placeholder="{{ ic.IngressClass.Type === $ctrl.IngressClassTypes.NGINX ? 'e.g. true or false' : 'e.g. 42' }}"
required
data-cy="namespaceCreate-annotationValue{{ ic.IngressClass.Name }}"
/>
</div>
<div class="input-group input-group-sm col-sm-1">
<button
class="btn btn-md btn-dangerlight btn-only-icon"
type="button"
ng-click="$ctrl.removeAnnotation(ic, $index)"
data-cy="namespaceCreate-deleteAnnotationButton{{ ic.IngressClass.Name }}"
>
<pr-icon icon="'trash-2'" size="'md'" feather="true"></pr-icon>
</button>
</div>
</div>
</div>
</div>
<!-- #endregion --> <!-- #endregion -->
</div> </div>
@ -422,6 +225,25 @@
</div> </div>
<!-- #endregion --> <!-- #endregion -->
<!-- #region STORAGES -->
<div class="col-sm-12 form-section-title"> Storage </div>
<div class="form-group">
<span class="col-sm-12 text-muted small vertical-center">
<pr-icon icon="'info'" mode="'primary'" feather="true"></pr-icon>
Quotas can be set on each storage option to prevent users from exceeding a specific threshold when deploying applications. You can set a quota to 0 to effectively
prevent the usage of a specific storage option inside this namespace.
</span>
</div>
<div class="col-sm-12 form-section-title vertical-center">
<pr-icon icon="'svg-route'"></pr-icon>
standard
</div>
<storage-class-switch value="sc.Selected" name="sc.Name" on-change="(ctrl.onToggleStorageQuota)" authorization="K8sResourcePoolDetailsW"></storage-class-switch>
<!-- #endregion -->
<!-- summary --> <!-- summary -->
<kubernetes-summary-view ng-if="resourcePoolCreationForm.$valid && !$ctrl.isCreateButtonDisabled()" form-values="$ctrl.formValues"></kubernetes-summary-view> <kubernetes-summary-view ng-if="resourcePoolCreationForm.$valid && !$ctrl.isCreateButtonDisabled()" form-values="$ctrl.formValues"></kubernetes-summary-view>
<!-- !summary --> <!-- !summary -->

View File

@ -2,20 +2,12 @@ import _ from 'lodash-es';
import filesizeParser from 'filesize-parser'; import filesizeParser from 'filesize-parser';
import { KubernetesResourceQuotaDefaults } from 'Kubernetes/models/resource-quota/models'; import { KubernetesResourceQuotaDefaults } from 'Kubernetes/models/resource-quota/models';
import KubernetesResourceReservationHelper from 'Kubernetes/helpers/resourceReservationHelper'; import KubernetesResourceReservationHelper from 'Kubernetes/helpers/resourceReservationHelper';
import { import { KubernetesResourcePoolFormValues, KubernetesResourcePoolIngressClassHostFormValue } from 'Kubernetes/models/resource-pool/formValues';
KubernetesResourcePoolFormValues,
KubernetesResourcePoolIngressClassAnnotationFormValue,
KubernetesResourcePoolIngressClassHostFormValue,
KubernetesResourcePoolNginxRewriteAnnotationFormValue,
KubernetesResourcePoolNginxUseregexAnnotationFormValue,
KubernetesResourcePoolTraefikRewriteAnnotationFormValue,
} from 'Kubernetes/models/resource-pool/formValues';
import { KubernetesIngressConverter } from 'Kubernetes/ingress/converter'; import { KubernetesIngressConverter } from 'Kubernetes/ingress/converter';
import KubernetesFormValidationHelper from 'Kubernetes/helpers/formValidationHelper';
import { KubernetesFormValidationReferences } from 'Kubernetes/models/application/formValues'; import { KubernetesFormValidationReferences } from 'Kubernetes/models/application/formValues';
import { KubernetesIngressClassTypes } from 'Kubernetes/ingress/constants'; import { KubernetesIngressClassTypes } from 'Kubernetes/ingress/constants';
import { FeatureId } from '@/portainer/feature-flags/enums'; import { FeatureId } from '@/portainer/feature-flags/enums';
import { getIngressControllerClassMap, updateIngressControllerClassMap } from '@/react/kubernetes/cluster/ingressClass/utils';
class KubernetesCreateResourcePoolController { class KubernetesCreateResourcePoolController {
/* #region CONSTRUCTOR */ /* #region CONSTRUCTOR */
@ -39,10 +31,17 @@ class KubernetesCreateResourcePoolController {
this.onToggleStorageQuota = this.onToggleStorageQuota.bind(this); this.onToggleStorageQuota = this.onToggleStorageQuota.bind(this);
this.onToggleLoadBalancerQuota = this.onToggleLoadBalancerQuota.bind(this); this.onToggleLoadBalancerQuota = this.onToggleLoadBalancerQuota.bind(this);
this.onToggleResourceQuota = this.onToggleResourceQuota.bind(this); this.onToggleResourceQuota = this.onToggleResourceQuota.bind(this);
this.onChangeIngressControllerAvailability = this.onChangeIngressControllerAvailability.bind(this);
this.onRegistriesChange = this.onRegistriesChange.bind(this); this.onRegistriesChange = this.onRegistriesChange.bind(this);
} }
/* #endregion */ /* #endregion */
onRegistriesChange(registries) {
return this.$scope.$evalAsync(() => {
this.formValues.Registries = registries;
});
}
onToggleStorageQuota(storageClassName, enabled) { onToggleStorageQuota(storageClassName, enabled) {
this.$scope.$evalAsync(() => { this.$scope.$evalAsync(() => {
this.formValues.StorageClasses = this.formValues.StorageClasses.map((sClass) => (sClass.Name !== storageClassName ? sClass : { ...sClass, Selected: enabled })); this.formValues.StorageClasses = this.formValues.StorageClasses.map((sClass) => (sClass.Name !== storageClassName ? sClass : { ...sClass, Selected: enabled }));
@ -61,74 +60,9 @@ class KubernetesCreateResourcePoolController {
}); });
} }
onChangeIngressHostname() { /* #region INGRESS MANAGEMENT */
const state = this.state.duplicates.ingressHosts; onChangeIngressControllerAvailability(controllerClassMap) {
const hosts = _.flatMap(this.formValues.IngressClasses, 'Hosts'); this.ingressControllers = controllerClassMap;
const hostnames = _.compact(hosts.map((h) => h.Host));
const hostnamesWithoutRemoved = _.filter(hostnames, (h) => !h.NeedsDeletion);
const allHosts = _.flatMap(this.allIngresses, 'Hosts');
const formDuplicates = KubernetesFormValidationHelper.getDuplicates(hostnamesWithoutRemoved);
_.forEach(hostnames, (host, idx) => {
if (host !== undefined && _.includes(allHosts, host)) {
formDuplicates[idx] = host;
}
});
const duplicates = {};
let count = 0;
_.forEach(this.formValues.IngressClasses, (ic) => {
duplicates[ic.IngressClass.Name] = {};
_.forEach(ic.Hosts, (hostFV, hostIdx) => {
if (hostFV.Host === formDuplicates[count]) {
duplicates[ic.IngressClass.Name][hostIdx] = hostFV.Host;
}
count++;
});
});
state.refs = duplicates;
state.hasRefs = false;
_.forIn(duplicates, (value) => {
if (Object.keys(value).length > 0) {
state.hasRefs = true;
}
});
}
onRegistriesChange(registries) {
return this.$scope.$evalAsync(() => {
this.formValues.Registries = registries;
});
}
addHostname(ingressClass) {
ingressClass.Hosts.push(new KubernetesResourcePoolIngressClassHostFormValue());
}
removeHostname(ingressClass, index) {
ingressClass.Hosts.splice(index, 1);
this.onChangeIngressHostname();
}
/* #region ANNOTATIONS MANAGEMENT */
addAnnotation(ingressClass) {
ingressClass.Annotations.push(new KubernetesResourcePoolIngressClassAnnotationFormValue());
}
addRewriteAnnotation(ingressClass) {
if (ingressClass.IngressClass.Type === this.IngressClassTypes.NGINX) {
ingressClass.Annotations.push(new KubernetesResourcePoolNginxRewriteAnnotationFormValue());
}
if (ingressClass.IngressClass.Type === this.IngressClassTypes.TRAEFIK) {
ingressClass.Annotations.push(new KubernetesResourcePoolTraefikRewriteAnnotationFormValue());
}
}
addUseregexAnnotation(ingressClass) {
ingressClass.Annotations.push(new KubernetesResourcePoolNginxUseregexAnnotationFormValue());
}
removeAnnotation(ingressClass, index) {
ingressClass.Annotations.splice(index, 1);
} }
/* #endregion */ /* #endregion */
@ -175,6 +109,7 @@ class KubernetesCreateResourcePoolController {
this.checkDefaults(); this.checkDefaults();
this.formValues.Owner = this.Authentication.getUserDetails().username; this.formValues.Owner = this.Authentication.getUserDetails().username;
await this.KubernetesResourcePoolService.create(this.formValues); await this.KubernetesResourcePoolService.create(this.formValues);
await updateIngressControllerClassMap(this.endpoint.Id, this.ingressControllers, this.formValues.Name);
this.Notifications.success('Namespace successfully created', this.formValues.Name); this.Notifications.success('Namespace successfully created', this.formValues.Name);
this.$state.go('kubernetes.resourcePools'); this.$state.go('kubernetes.resourcePools');
} catch (err) { } catch (err) {
@ -239,15 +174,21 @@ class KubernetesCreateResourcePoolController {
viewReady: false, viewReady: false,
isAlreadyExist: false, isAlreadyExist: false,
hasPrefixKube: false, hasPrefixKube: false,
canUseIngress: endpoint.Kubernetes.Configuration.IngressClasses.length, canUseIngress: false,
duplicates: { duplicates: {
ingressHosts: new KubernetesFormValidationReferences(), ingressHosts: new KubernetesFormValidationReferences(),
}, },
isAdmin: this.Authentication.isAdmin(), isAdmin: this.Authentication.isAdmin(),
ingressAvailabilityPerNamespace: endpoint.Kubernetes.Configuration.IngressAvailabilityPerNamespace,
}; };
const nodes = await this.KubernetesNodeService.get(); const nodes = await this.KubernetesNodeService.get();
this.ingressControllers = [];
if (this.state.ingressAvailabilityPerNamespace) {
this.ingressControllers = await getIngressControllerClassMap({ environmentId: this.endpoint.Id, allowedOnly: true });
}
_.forEach(nodes, (item) => { _.forEach(nodes, (item) => {
this.state.sliderMaxMemory += filesizeParser(item.Memory); this.state.sliderMaxMemory += filesizeParser(item.Memory);
this.state.sliderMaxCpu += item.CPU; this.state.sliderMaxCpu += item.CPU;

View File

@ -156,193 +156,20 @@
</div> </div>
</div> </div>
<!-- #endregion --> <!-- #endregion -->
<div ng-if="ctrl.isAdmin && ctrl.isEditable && ctrl.state.canUseIngress"> <div ng-if="ctrl.isAdmin && ctrl.isEditable && ctrl.state.ingressAvailabilityPerNamespace">
<div class="col-sm-12 form-section-title"> Ingresses </div>
<!-- #region INGRESSES --> <!-- #region INGRESSES -->
<div class="form-group" ng-if="ctrl.formValues.IngressClasses.length === 0"> <div class="col-sm-12 form-section-title"> Networking </div>
<div class="col-sm-12 small text-muted"> <ingress-class-datatable
The ingress feature must be enabled in the ng-if="ctrl.state.ingressAvailabilityPerNamespace"
<a ui-sref="kubernetes.cluster.setup">environment configuration view</a> to be able to register ingresses inside this namespace. on-change-availability="(ctrl.onChangeIngressControllerAvailability)"
</div> ingress-controllers="ctrl.ingressControllers"
</div> description="'Enable the ingress controllers that users can select when publishing applications in this namespace.'"
no-ingress-controller-label="'No ingress controllers found in the cluster. Go to the cluster setup view to configure and allow the use of ingress controllers in the cluster.'"
view="'namespace'"
></ingress-class-datatable>
<div class="form-group" ng-if="ctrl.formValues.IngressClasses.length > 0">
<div class="col-sm-12 small text-muted">
<p class="vertical-center">
<pr-icon icon="'info'" mode="'primary'" feather="true"></pr-icon>
Enable and configure ingresses available to users when deploying applications.
</p>
</div>
</div>
<div class="form-group" ng-repeat-start="ic in ctrl.formValues.IngressClasses track by ic.IngressClass.Name">
<div class="text-muted col-sm-12" style="width: 100%">
<pr-icon icon="'svg-route'" mode="'primary'"></pr-icon>
{{ ic.IngressClass.Name }}
<hr />
</div>
<div class="col-sm-12">
<div class="form-group">
<div class="col-sm-3 col-lg-2">
<label class="control-label text-left"> Allow users to use this ingress </label>
</div>
<div class="col-sm-9 pt-2">
<label class="switch">
<input type="checkbox" ng-model="ic.Selected" />
<span class="slider round"></span>
</label>
</div>
</div>
</div>
</div>
<div ng-if="ic.Selected">
<div class="form-group">
<div class="col-sm-12">
<label class="control-label text-left">
Hostnames
<portainer-tooltip
message="'Hostnames associated to the ingress inside this namespace. Users will be able to expose and access their applications over the ingress via one of these hostname.'"
>
</portainer-tooltip>
</label>
<span class="vertical-center label label-default interactive" style="margin-left: 10px" ng-click="ctrl.addHostname(ic)">
<pr-icon icon="'plus'" feather="true"></pr-icon> add hostname
</span>
</div>
<div class="col-sm-12" style="margin-top: 10px">
<div ng-repeat="item in ic.Hosts track by $index" style="margin-top: 2px">
<div class="form-inline">
<div class="col-sm-10 input-group input-group-sm" ng-class="{ striked: item.NeedsDeletion }">
<span class="input-group-addon required">Hostname</span>
<input
type="text"
class="form-control"
name="hostname_{{ ic.IngressClass.Name }}_{{ $index }}"
ng-model="item.Host"
ng-change="ctrl.onChangeIngressHostname()"
placeholder="foo"
pattern="[\*a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*"
required
/>
</div>
<div class="col-sm-1 input-group input-group-sm" ng-if="$index > 0">
<button ng-if="!item.NeedsDeletion" class="btn btn-sm btn-dangerlight" type="button" ng-click="ctrl.removeHostname(ic, $index)">
<pr-icon icon="'trash-2'" feather="true"></pr-icon>
</button>
<button ng-if="item.NeedsDeletion" class="btn btn-sm btn-primary" type="button" ng-click="ctrl.restoreHostname(item)">
<pr-icon icon="'svg-restore'"></pr-icon>
</button>
</div>
</div>
<div
class="small text-warning"
style="margin-top: 5px"
ng-show="resourcePoolEditForm['hostname_' + ic.IngressClass.Name + '_' + $index].$invalid || item.Duplicate"
>
<ng-messages for="resourcePoolEditForm['hostname_' + ic.IngressClass.Name + '_' + $index].$error">
<p ng-message="required" class="vertical-center">
<pr-icon icon="'alert-triangle'" feather="true" mode="'warning'"></pr-icon>
Hostname is required.
</p>
<p ng-message="pattern" class="vertical-center">
<pr-icon icon="'alert-triangle'" feather="true" mode="'warning'"></pr-icon>
This field must consist of lower case alphanumeric characters, '-' or '.', and must start and end with an alphanumeric character (e.g.
'example.com').
</p>
</ng-messages>
<p class="vertical-center" ng-if="item.Duplicate">
<pr-icon icon="'alert-triangle'" feather="true" mode="'warning'"></pr-icon>
This hostname is already used.
</p>
</div>
</div>
</div>
</div>
</div>
<div ng-repeat-end class="form-group" ng-if="ic.Selected" style="margin-bottom: 20px">
<div class="col-sm-12 small text-muted" style="margin-top: 5px">
<p class="vertical-center">
<pr-icon icon="'info'" mode="'primary'" feather="true"></pr-icon>
You can specify a list of annotations that will be associated to the ingress.
</p>
</div>
<div class="col-sm-12">
<label class="control-label text-left">Annotations</label>
<span class="vertical-center label label-default interactive" style="margin-left: 10px" ng-click="ctrl.addAnnotation(ic)">
<pr-icon icon="'plus'" feather="true"></pr-icon> add annotation
</span>
<portainer-tooltip
message="'Use annotations to configure options for an ingress. Review Nginx or Traefik documentation to find the annotations supported by your choice of ingress type.'"
>
</portainer-tooltip>
<span
class="vertical-center label label-default interactive"
ng-if="ic.IngressClass.Type === ctrl.IngressClassTypes.NGINX"
style="margin-left: 10px"
ng-click="ctrl.addRewriteAnnotation(ic)"
>
<pr-icon icon="'plus'" feather="true"></pr-icon> add rewrite annotation
</span>
<portainer-tooltip
ng-if="ic.IngressClass.Type === ctrl.IngressClassTypes.NGINX"
message="'When the exposed URLs for your applications differ from the specified paths in the ingress, use the rewrite target annotation to denote the path to redirect to.'"
>
</portainer-tooltip>
<span
class="vertical-center label label-default interactive"
ng-if="ic.IngressClass.Type === ctrl.IngressClassTypes.NGINX"
style="margin-left: 10px"
ng-click="ctrl.addUseregexAnnotation(ic)"
>
<pr-icon icon="'plus'" feather="true"></pr-icon> add regular expression annotation
</span>
<portainer-tooltip
ng-if="ic.IngressClass.Type === ctrl.IngressClassTypes.NGINX"
message="'Enable use of regular expressions in ingress paths (set in the ingress details of an application). Use this along with rewrite-target to specify the regex capturing group to be replaced, e.g. path regex of ^/foo/(,*) and rewrite-target of /bar/$1 rewrites example.com/foo/account to example.com/bar/account.'"
>
</portainer-tooltip>
</div>
<div class="col-sm-12 form-inline" style="margin-top: 10px">
<div ng-repeat="annotation in ic.Annotations track by $index" style="margin-top: 2px">
<div class="input-group col-sm-5 input-group-sm">
<span class="input-group-addon">Key</span>
<input
type="text"
class="form-control"
ng-model="annotation.Key"
placeholder="{{
ic.IngressClass.Type === ctrl.IngressClassTypes.NGINX
? 'e.g. nginx.ingress.kubernetes.io/enable-rewrite-log'
: 'e.g. traefik.ingress.kubernetes.io/router.priority'
}}"
required
/>
</div>
<div class="input-group col-sm-5 input-group-sm">
<span class="input-group-addon">Value</span>
<input
type="text"
class="form-control"
ng-model="annotation.Value"
placeholder="{{ ic.IngressClass.Type === ctrl.IngressClassTypes.NGINX ? 'e.g. true or false' : 'e.g. 42' }}"
required
/>
</div>
<div class="col-sm-1 input-group input-group-sm">
<button class="btn btn-md btn-dangerlight btn-only-icon" type="button" ng-click="ctrl.removeAnnotation(ic, $index)">
<pr-icon icon="'trash-2'" feather="true" size="'md'"></pr-icon>
</button>
</div>
</div>
</div>
</div>
<!-- #endregion --> <!-- #endregion -->
</div> </div>
<!-- #region REGISTRIES --> <!-- #region REGISTRIES -->
<div> <div>
<div class="col-sm-12 form-section-title"> Registries </div> <div class="col-sm-12 form-section-title"> Registries </div>

View File

@ -5,21 +5,15 @@ import { KubernetesResourceQuotaDefaults } from 'Kubernetes/models/resource-quot
import KubernetesResourceReservationHelper from 'Kubernetes/helpers/resourceReservationHelper'; import KubernetesResourceReservationHelper from 'Kubernetes/helpers/resourceReservationHelper';
import { KubernetesResourceReservation } from 'Kubernetes/models/resource-reservation/models'; import { KubernetesResourceReservation } from 'Kubernetes/models/resource-reservation/models';
import KubernetesEventHelper from 'Kubernetes/helpers/eventHelper'; import KubernetesEventHelper from 'Kubernetes/helpers/eventHelper';
import {
KubernetesResourcePoolFormValues, import { KubernetesResourcePoolFormValues, KubernetesResourcePoolIngressClassHostFormValue } from 'Kubernetes/models/resource-pool/formValues';
KubernetesResourcePoolIngressClassAnnotationFormValue,
KubernetesResourcePoolIngressClassHostFormValue,
KubernetesResourcePoolNginxRewriteAnnotationFormValue,
KubernetesResourcePoolNginxUseregexAnnotationFormValue,
KubernetesResourcePoolTraefikRewriteAnnotationFormValue,
} from 'Kubernetes/models/resource-pool/formValues';
import { KubernetesIngressConverter } from 'Kubernetes/ingress/converter'; import { KubernetesIngressConverter } from 'Kubernetes/ingress/converter';
import { KubernetesFormValidationReferences } from 'Kubernetes/models/application/formValues'; import { KubernetesFormValidationReferences } from 'Kubernetes/models/application/formValues';
import KubernetesFormValidationHelper from 'Kubernetes/helpers/formValidationHelper';
import { KubernetesIngressClassTypes } from 'Kubernetes/ingress/constants'; import { KubernetesIngressClassTypes } from 'Kubernetes/ingress/constants';
import KubernetesResourceQuotaConverter from 'Kubernetes/converters/resourceQuota'; import KubernetesResourceQuotaConverter from 'Kubernetes/converters/resourceQuota';
import KubernetesNamespaceHelper from 'Kubernetes/helpers/namespaceHelper'; import KubernetesNamespaceHelper from 'Kubernetes/helpers/namespaceHelper';
import { FeatureId } from '@/portainer/feature-flags/enums'; import { FeatureId } from '@/portainer/feature-flags/enums';
import { updateIngressControllerClassMap, getIngressControllerClassMap } from '@/react/kubernetes/cluster/ingressClass/utils';
class KubernetesResourcePoolController { class KubernetesResourcePoolController {
/* #region CONSTRUCTOR */ /* #region CONSTRUCTOR */
@ -73,35 +67,11 @@ class KubernetesResourcePoolController {
this.getEvents = this.getEvents.bind(this); this.getEvents = this.getEvents.bind(this);
this.onToggleLoadBalancersQuota = this.onToggleLoadBalancersQuota.bind(this); this.onToggleLoadBalancersQuota = this.onToggleLoadBalancersQuota.bind(this);
this.onToggleStorageQuota = this.onToggleStorageQuota.bind(this); this.onToggleStorageQuota = this.onToggleStorageQuota.bind(this);
this.onChangeIngressControllerAvailability = this.onChangeIngressControllerAvailability.bind(this);
this.onRegistriesChange = this.onRegistriesChange.bind(this); this.onRegistriesChange = this.onRegistriesChange.bind(this);
} }
/* #endregion */ /* #endregion */
/* #region ANNOTATIONS MANAGEMENT */
addAnnotation(ingressClass) {
ingressClass.Annotations.push(new KubernetesResourcePoolIngressClassAnnotationFormValue());
}
addRewriteAnnotation(ingressClass) {
if (ingressClass.IngressClass.Type === this.IngressClassTypes.NGINX) {
ingressClass.Annotations.push(new KubernetesResourcePoolNginxRewriteAnnotationFormValue());
}
if (ingressClass.IngressClass.Type === this.IngressClassTypes.TRAEFIK) {
ingressClass.Annotations.push(new KubernetesResourcePoolTraefikRewriteAnnotationFormValue());
}
}
addUseregexAnnotation(ingressClass) {
ingressClass.Annotations.push(new KubernetesResourcePoolNginxUseregexAnnotationFormValue());
}
removeAnnotation(ingressClass, index) {
ingressClass.Annotations.splice(index, 1);
this.onChangeIngressHostname();
}
/* #endregion */
onRegistriesChange(registries) { onRegistriesChange(registries) {
return this.$scope.$evalAsync(() => { return this.$scope.$evalAsync(() => {
this.formValues.Registries = registries; this.formValues.Registries = registries;
@ -120,55 +90,10 @@ class KubernetesResourcePoolController {
}); });
} }
/* #region INGRESS MANAGEMENT */ onChangeIngressControllerAvailability(controllerClassMap) {
onChangeIngressHostname() { this.ingressControllers = controllerClassMap;
const state = this.state.duplicates.ingressHosts;
const otherIngresses = _.without(this.allIngresses, ...this.ingresses);
const allHosts = _.flatMap(otherIngresses, 'Hosts');
const hosts = _.flatMap(this.formValues.IngressClasses, 'Hosts');
const hostsWithoutRemoved = _.filter(hosts, { NeedsDeletion: false });
const hostnames = _.map(hostsWithoutRemoved, 'Host');
const formDuplicates = KubernetesFormValidationHelper.getDuplicates(hostnames);
_.forEach(hostnames, (host, idx) => {
if (host !== undefined && _.includes(allHosts, host)) {
formDuplicates[idx] = host;
}
});
const duplicatedHostnames = Object.values(formDuplicates);
state.hasRefs = false;
_.forEach(this.formValues.IngressClasses, (ic) => {
_.forEach(ic.Hosts, (hostFV) => {
if (_.includes(duplicatedHostnames, hostFV.Host) && hostFV.NeedsDeletion === false) {
hostFV.Duplicate = true;
state.hasRefs = true;
} else {
hostFV.Duplicate = false;
}
});
});
} }
addHostname(ingressClass) {
ingressClass.Hosts.push(new KubernetesResourcePoolIngressClassHostFormValue());
}
removeHostname(ingressClass, index) {
if (!ingressClass.Hosts[index].IsNew) {
ingressClass.Hosts[index].NeedsDeletion = true;
} else {
ingressClass.Hosts.splice(index, 1);
}
this.onChangeIngressHostname();
}
restoreHostname(host) {
if (!host.IsNew) {
host.NeedsDeletion = false;
}
}
/* #endregion*/
selectTab(index) { selectTab(index) {
this.LocalStorage.storeActiveTab('resourcePool', index); this.LocalStorage.storeActiveTab('resourcePool', index);
} }
@ -219,6 +144,7 @@ class KubernetesResourcePoolController {
try { try {
this.checkDefaults(); this.checkDefaults();
await this.KubernetesResourcePoolService.patch(oldFormValues, newFormValues); await this.KubernetesResourcePoolService.patch(oldFormValues, newFormValues);
await updateIngressControllerClassMap(this.endpoint.Id, this.ingressControllers, this.formValues.Name);
this.Notifications.success('Namespace successfully updated', this.pool.Namespace.Name); this.Notifications.success('Namespace successfully updated', this.pool.Namespace.Name);
this.$state.reload(this.$state.current); this.$state.reload(this.$state.current);
} catch (err) { } catch (err) {
@ -426,11 +352,12 @@ class KubernetesResourcePoolController {
ingressesLoading: true, ingressesLoading: true,
viewReady: false, viewReady: false,
eventWarningCount: 0, eventWarningCount: 0,
canUseIngress: this.endpoint.Kubernetes.Configuration.IngressClasses.length, canUseIngress: false,
useServerMetrics: this.endpoint.Kubernetes.Configuration.UseServerMetrics, useServerMetrics: this.endpoint.Kubernetes.Configuration.UseServerMetrics,
duplicates: { duplicates: {
ingressHosts: new KubernetesFormValidationReferences(), ingressHosts: new KubernetesFormValidationReferences(),
}, },
ingressAvailabilityPerNamespace: this.endpoint.Kubernetes.Configuration.IngressAvailabilityPerNamespace,
}; };
this.state.activeTab = this.LocalStorage.getActiveTab('resourcePool'); this.state.activeTab = this.LocalStorage.getActiveTab('resourcePool');
@ -439,6 +366,11 @@ class KubernetesResourcePoolController {
const [nodes, pools] = await Promise.all([this.KubernetesNodeService.get(), this.KubernetesResourcePoolService.get()]); const [nodes, pools] = await Promise.all([this.KubernetesNodeService.get(), this.KubernetesResourcePoolService.get()]);
this.ingressControllers = [];
if (this.state.ingressAvailabilityPerNamespace) {
this.ingressControllers = await getIngressControllerClassMap({ environmentId: this.endpoint.Id, namespace: name });
}
this.pool = _.find(pools, { Namespace: { Name: name } }); this.pool = _.find(pools, { Namespace: { Name: name } });
this.formValues = new KubernetesResourcePoolFormValues(KubernetesResourceQuotaDefaults); this.formValues = new KubernetesResourcePoolFormValues(KubernetesResourceQuotaDefaults);
this.formValues.Name = this.pool.Namespace.Name; this.formValues.Name = this.pool.Namespace.Name;

View File

@ -25,6 +25,7 @@ import { Loading } from '@@/Widget/Loading';
import { PasswordCheckHint } from '@@/PasswordCheckHint'; import { PasswordCheckHint } from '@@/PasswordCheckHint';
import { ViewLoading } from '@@/ViewLoading'; import { ViewLoading } from '@@/ViewLoading';
import { Tooltip } from '@@/Tip/Tooltip'; import { Tooltip } from '@@/Tip/Tooltip';
import { Badge } from '@@/Badge';
import { TableColumnHeaderAngular } from '@@/datatables/TableHeaderCell'; import { TableColumnHeaderAngular } from '@@/datatables/TableHeaderCell';
import { DashboardItem } from '@@/DashboardItem'; import { DashboardItem } from '@@/DashboardItem';
import { SearchBar } from '@@/datatables/SearchBar'; import { SearchBar } from '@@/datatables/SearchBar';
@ -47,6 +48,7 @@ export const componentsModule = angular
'portainerTooltip', 'portainerTooltip',
r2a(Tooltip, ['message', 'position', 'className']) r2a(Tooltip, ['message', 'position', 'className'])
) )
.component('badge', r2a(Badge, ['type', 'className']))
.component('fileUploadField', fileUploadField) .component('fileUploadField', fileUploadField)
.component('porSwitchField', switchField) .component('porSwitchField', switchField)
.component( .component(

View File

@ -99,7 +99,7 @@
<div class="form-group overflow-auto" ng-if="ctrl.state.showStandardLogin"> <div class="form-group overflow-auto" ng-if="ctrl.state.showStandardLogin">
<!-- login button --> <!-- login button -->
<div class="col-sm-12 d-flex"> <div class="col-sm-12 d-flex py-1">
<button <button
type="submit" type="submit"
class="btn btn-primary btn-lg btn-block" class="btn btn-primary btn-lg btn-block"

View File

@ -0,0 +1,29 @@
import { Meta } from '@storybook/react';
import { Badge, Props } from './Badge';
export default {
component: Badge,
title: 'Components/Badge',
argTypes: {
type: {
control: {
type: 'select',
options: ['success', 'danger', 'warn', 'info'],
},
},
},
} as Meta<Props>;
// : JSX.IntrinsicAttributes & PropsWithChildren<Props>
function Template({ type = 'success' }: Props) {
const message = {
success: 'success badge',
danger: 'danger badge',
warn: 'warn badge',
info: 'info badge',
};
return <Badge type={type}>{message[type]}</Badge>;
}
export const Example = Template.bind({});

View File

@ -0,0 +1,59 @@
import clsx from 'clsx';
import { PropsWithChildren } from 'react';
type BadgeType = 'success' | 'danger' | 'warn' | 'info';
export interface Props {
type?: BadgeType;
className?: string;
}
// this component is used in tables and lists in portainer. It looks like this:
// https://www.figma.com/file/g5TUMngrblkXM7NHSyQsD1/New-UI?node-id=76%3A2
export function Badge({ type, className, children }: PropsWithChildren<Props>) {
const baseClasses =
'flex w-fit items-center !text-xs font-medium rounded-full px-2 py-0.5';
const typeClasses = getClasses(type);
return (
<span className={clsx(baseClasses, typeClasses, className)}>
{children}
</span>
);
}
// classes in full to prevent a dev server bug, where tailwind doesn't render the interpolated classes
function getClasses(type: BadgeType | undefined) {
switch (type) {
case 'success':
return clsx(
`text-success-9 bg-success-2`,
`th-dark:text-success-3 th-dark:bg-success-10`,
`th-highcontrast:text-success-3 th-highcontrast:bg-success-10`
);
case 'warn':
return clsx(
`text-warning-9 bg-warning-2`,
`th-dark:text-warning-3 th-dark:bg-warning-10`,
`th-highcontrast:text-warning-3 th-highcontrast:bg-warning-10`
);
case 'danger':
return clsx(
`text-error-9 bg-error-2`,
`th-dark:text-error-3 th-dark:bg-error-10`,
`th-highcontrast:text-error-3 th-highcontrast:bg-error-10`
);
case 'info':
return clsx(
`text-blue-9 bg-blue-2`,
`th-dark:text-blue-3 th-dark:bg-blue-10`,
`th-highcontrast:text-blue-3 th-highcontrast:bg-blue-10`
);
default:
return clsx(
`text-blue-9 bg-blue-2`,
`th-dark:text-blue-3 th-dark:bg-blue-10`,
`th-highcontrast:text-blue-3 th-highcontrast:bg-blue-10`
);
}
}

View File

@ -0,0 +1 @@
export { Badge } from './Badge';

View File

@ -54,6 +54,7 @@ interface Props<
initialTableState?: Partial<TableState<D>>; initialTableState?: Partial<TableState<D>>;
isLoading?: boolean; isLoading?: boolean;
totalCount?: number; totalCount?: number;
description?: JSX.Element;
} }
export function Datatable< export function Datatable<
@ -74,6 +75,7 @@ export function Datatable<
initialTableState = {}, initialTableState = {},
isLoading, isLoading,
totalCount = dataset.length, totalCount = dataset.length,
description,
}: Props<D, TSettings>) { }: Props<D, TSettings>) {
const [searchBarValue, setSearchBarValue] = useSearchBarState(storageKey); const [searchBarValue, setSearchBarValue] = useSearchBarState(storageKey);
@ -145,6 +147,7 @@ export function Datatable<
label={titleOptions.title} label={titleOptions.title}
icon={titleOptions.icon} icon={titleOptions.icon}
featherIcon={titleOptions.featherIcon} featherIcon={titleOptions.featherIcon}
description={description}
> >
<SearchBar value={searchBarValue} onChange={setGlobalFilter} /> <SearchBar value={searchBarValue} onChange={setGlobalFilter} />
{renderTableActions && ( {renderTableActions && (

View File

@ -20,8 +20,8 @@ export function SearchBar({
const [searchValue, setSearchValue] = useDebounce(value, onChange); const [searchValue, setSearchValue] = useDebounce(value, onChange);
return ( return (
<div className="searchBar items-center flex"> <div className="searchBar items-center flex min-w-[90px]">
<Search className="searchIcon feather" /> <Search className="searchIcon feather shrink-0" />
<input <input
type="text" type="text"
className="searchInput" className="searchInput"

View File

@ -22,7 +22,7 @@ export function TableHeaderCell({
render, render,
onSortClick, onSortClick,
isSorted, isSorted,
isSortedDesc, isSortedDesc = true,
canFilter, canFilter,
renderFilter, renderFilter,
}: Props) { }: Props) {
@ -55,7 +55,7 @@ function SortWrapper({
children, children,
onClick = () => {}, onClick = () => {},
isSorted, isSorted,
isSortedDesc, isSortedDesc = true,
}: PropsWithChildren<SortWrapperProps>) { }: PropsWithChildren<SortWrapperProps>) {
if (!canSort) { if (!canSort) {
return <>{children}</>; return <>{children}</>;
@ -93,7 +93,7 @@ export function TableColumnHeaderAngular({
canSort, canSort,
isSorted, isSorted,
colTitle, colTitle,
isSortedDesc, isSortedDesc = true,
}: TableColumnHeaderAngularProps) { }: TableColumnHeaderAngularProps) {
return ( return (
<div className="flex flex-row flex-nowrap h-full"> <div className="flex flex-row flex-nowrap h-full">

View File

@ -6,6 +6,7 @@ interface Props {
icon?: ReactNode | ComponentType<unknown>; icon?: ReactNode | ComponentType<unknown>;
featherIcon?: boolean; featherIcon?: boolean;
label: string; label: string;
description?: JSX.Element;
} }
export function TableTitle({ export function TableTitle({
@ -13,23 +14,27 @@ export function TableTitle({
featherIcon, featherIcon,
label, label,
children, children,
description,
}: PropsWithChildren<Props>) { }: PropsWithChildren<Props>) {
return ( return (
<div className="toolBar"> <div className="toolBar flex-col">
<div className="toolBarTitle"> <div className="flex gap-1 p-0 w-full items-center">
{icon && ( <div className="toolBarTitle">
<div className="widget-icon"> {icon && (
<Icon <div className="widget-icon">
icon={icon} <Icon
feather={featherIcon} icon={icon}
className="space-right feather" feather={featherIcon}
/> className="space-right feather"
</div> />
)} </div>
)}
{label} {label}
</div>
{children}
</div> </div>
{children} {description && description}
</div> </div>
); );
} }

View File

@ -0,0 +1,221 @@
import { useState } from 'react';
import { confirmWarn } from '@/portainer/services/modal.service/confirm';
import { Datatable } from '@@/datatables';
import { Button, ButtonGroup } from '@@/buttons';
import { Icon } from '@@/Icon';
import { IngressControllerClassMap } from '../types';
import { useColumns } from './columns';
import { createStore } from './datatable-store';
const useStore = createStore('ingressClasses');
interface Props {
onChangeAvailability: (
controllerClassMap: IngressControllerClassMap[]
) => void; // angular function to save the ingress class list
description: string;
ingressControllers: IngressControllerClassMap[] | undefined;
noIngressControllerLabel: string;
view: string;
}
export function IngressClassDatatable({
onChangeAvailability,
description,
ingressControllers,
noIngressControllerLabel,
view,
}: Props) {
const [ingControllerFormValues, setIngControllerFormValues] =
useState(ingressControllers);
const settings = useStore();
const columns = useColumns();
return (
<div className="-mx-[15px]">
<Datatable
dataset={ingControllerFormValues || []}
storageKey="ingressClasses"
columns={columns}
settingsStore={settings}
isLoading={!ingControllerFormValues}
emptyContentLabel={noIngressControllerLabel}
titleOptions={{
icon: 'database',
title: 'Ingress controllers',
featherIcon: true,
}}
getRowId={(row) => `${row.Name}-${row.ClassName}-${row.Type}`}
renderTableActions={(selectedRows) => renderTableActions(selectedRows)}
description={renderIngressClassDescription()}
/>
</div>
);
function renderTableActions(selectedRows: IngressControllerClassMap[]) {
return (
<div className="flex items-start">
<ButtonGroup>
<Button
disabled={
selectedRows.filter((row) => row.Availability === true).length ===
0
}
color="dangerlight"
size="small"
onClick={() =>
updateIngressControllers(
selectedRows,
ingControllerFormValues || [],
false
)
}
>
Disallow selected
</Button>
<Button
disabled={
selectedRows.filter((row) => row.Availability === false)
.length === 0
}
color="default"
size="small"
onClick={() =>
updateIngressControllers(
selectedRows,
ingControllerFormValues || [],
true
)
}
>
Allow selected
</Button>
</ButtonGroup>
</div>
);
}
function renderIngressClassDescription() {
return (
<div className="flex flex-col !text-xs text-muted w-full">
<div className="mt-1">{description}</div>
{ingressControllers &&
ingControllerFormValues &&
isUnsavedChanges(ingressControllers, ingControllerFormValues) && (
<span className="flex items-center text-warning mt-1">
<Icon icon="alert-triangle" feather className="!mr-1" />
<span className="text-warning">Unsaved changes.</span>
</span>
)}
</div>
);
}
function updateIngressControllers(
selectedRows: IngressControllerClassMap[],
ingControllerFormValues: IngressControllerClassMap[],
availability: boolean
) {
const updatedIngressControllers = getUpdatedIngressControllers(
selectedRows,
ingControllerFormValues || [],
availability
);
if (ingressControllers && ingressControllers.length) {
const newAllowed = updatedIngressControllers.map(
(ingController) => ingController.Availability
);
if (view === 'namespace') {
setIngControllerFormValues(updatedIngressControllers);
onChangeAvailability(updatedIngressControllers);
return;
}
const usedControllersToDisallow = ingressControllers.filter(
(ingController, index) => {
// if any of the current controllers are allowed, and are used, then become disallowed, then add the controller to a new list
if (
ingController.Availability &&
ingController.Used &&
!newAllowed[index]
) {
return true;
}
return false;
}
);
if (usedControllersToDisallow.length > 0) {
const usedControllerHtmlListItems = usedControllersToDisallow.map(
(controller) => `<li>${controller.ClassName}</li>`
);
const usedControllerHtmlList = `<ul class="ml-6">${usedControllerHtmlListItems.join(
''
)}</ul>`;
confirmWarn({
title: 'Disallow in-use ingress controllers?',
message: `
<div>
<p>There are ingress controllers you want to disallow that are in use:</p>
${usedControllerHtmlList}
<p>No new ingress rules can be created for the disallowed controllers.</p>
</div>`,
buttons: {
cancel: {
label: 'Cancel',
className: 'btn-default',
},
confirm: {
label: 'Disallow',
className: 'btn-warning',
},
},
callback: (confirmed) => {
if (confirmed) {
setIngControllerFormValues(updatedIngressControllers);
onChangeAvailability(updatedIngressControllers);
}
},
});
return;
}
setIngControllerFormValues(updatedIngressControllers);
onChangeAvailability(updatedIngressControllers);
}
}
}
function isUnsavedChanges(
oldIngressControllers: IngressControllerClassMap[],
newIngressControllers: IngressControllerClassMap[]
) {
for (let i = 0; i < oldIngressControllers.length; i += 1) {
if (
oldIngressControllers[i].Availability !==
newIngressControllers[i].Availability
) {
return true;
}
}
return false;
}
function getUpdatedIngressControllers(
selectedRows: IngressControllerClassMap[],
allRows: IngressControllerClassMap[],
allow: boolean
) {
const selectedRowClassNames = selectedRows.map((row) => row.ClassName);
const updatedIngressControllers = allRows?.map((row) => {
if (selectedRowClassNames.includes(row.ClassName)) {
return { ...row, Availability: allow };
}
return row;
});
return updatedIngressControllers;
}

View File

@ -0,0 +1,11 @@
import { Environment } from '@/portainer/environments/types';
import { createRowContext } from '@@/datatables/RowContext';
interface RowContextState {
environment: Environment;
}
const { RowProvider, useRowContext } = createRowContext<RowContextState>();
export { RowProvider, useRowContext };

View File

@ -0,0 +1,27 @@
import { CellProps, Column } from 'react-table';
import { Badge } from '@@/Badge';
import { Icon } from '@@/Icon';
import type { IngressControllerClassMap } from '../../types';
export const availability: Column<IngressControllerClassMap> = {
Header: 'Availability',
accessor: 'Availability',
Cell: AvailailityCell,
id: 'availability',
disableFilters: true,
canHide: true,
sortInverted: true,
sortType: 'basic',
Filter: () => null,
};
function AvailailityCell({ value }: CellProps<IngressControllerClassMap>) {
return (
<Badge type={value ? 'success' : 'danger'}>
<Icon icon={value ? 'check' : 'x'} feather className="!mr-1" />
{value ? 'Allowed' : 'Disallowed'}
</Badge>
);
}

View File

@ -0,0 +1,9 @@
import { useMemo } from 'react';
import { availability } from './availability';
import { type } from './type';
import { name } from './name';
export function useColumns() {
return useMemo(() => [name, type, availability], []);
}

View File

@ -0,0 +1,25 @@
import { CellProps, Column } from 'react-table';
import { Badge } from '@@/Badge';
import type { IngressControllerClassMap } from '../../types';
export const name: Column<IngressControllerClassMap> = {
Header: 'Ingress class',
accessor: 'ClassName',
Cell: NameCell,
id: 'name',
disableFilters: true,
canHide: true,
Filter: () => null,
sortType: 'string',
};
function NameCell({ row }: CellProps<IngressControllerClassMap>) {
return (
<span className="flex flex-nowrap">
{row.original.ClassName}
{row.original.New && <Badge className="ml-1">Newly detected</Badge>}
</span>
);
}

View File

@ -0,0 +1,14 @@
import { CellProps, Column } from 'react-table';
import type { IngressControllerClassMap } from '../../types';
export const type: Column<IngressControllerClassMap> = {
Header: 'Ingress controller type',
accessor: 'Type',
Cell: ({ row }: CellProps<IngressControllerClassMap>) =>
row.original.Type || '-',
id: 'type',
disableFilters: true,
canHide: true,
Filter: () => null,
};

View File

@ -0,0 +1,26 @@
import create from 'zustand';
import { persist } from 'zustand/middleware';
import { keyBuilder } from '@/portainer/hooks/useLocalStorage';
import {
paginationSettings,
sortableSettings,
} from '@/react/components/datatables/types';
import { TableSettings } from './types';
export const TRUNCATE_LENGTH = 32;
export function createStore(storageKey: string) {
return create<TableSettings>()(
persist(
(set) => ({
...sortableSettings(set),
...paginationSettings(set),
}),
{
name: keyBuilder(storageKey),
}
)
);
}

View File

@ -0,0 +1 @@
export * from './IngressClassDatatable';

View File

@ -0,0 +1,8 @@
import {
PaginationTableSettings,
SortableTableSettings,
} from '@/react/components/datatables/types';
export interface TableSettings
extends SortableTableSettings,
PaginationTableSettings {}

View File

@ -0,0 +1,23 @@
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { EnvironmentId } from '@/portainer/environments/types';
import {
KubernetesApiListResponse,
V1IngressClass,
} from '@/react/kubernetes/services/kubernetes/types';
export async function getAllIngressClasses(environmentId: EnvironmentId) {
try {
const {
data: { items },
} = await axios.get<KubernetesApiListResponse<V1IngressClass[]>>(
urlBuilder(environmentId)
);
return items;
} catch (error) {
throw parseAxiosError(error as Error);
}
}
function urlBuilder(environmentId: EnvironmentId) {
return `endpoints/${environmentId}/kubernetes/apis/networking.k8s.io/v1/ingressclasses`;
}

View File

@ -0,0 +1,10 @@
type SupportedIngControllerNames = 'nginx' | 'traefik' | 'unknown';
export interface IngressControllerClassMap extends Record<string, unknown> {
Name: string;
ClassName: string;
Type: SupportedIngControllerNames;
Availability: boolean;
New: boolean;
Used: boolean; // if the controller is used by any ingress in the cluster
}

View File

@ -0,0 +1,57 @@
import { EnvironmentId } from '@/portainer/environments/types';
import PortainerError from '@/portainer/error';
import axios from '@/portainer/services/axios';
import { IngressControllerClassMap } from '../types';
// get all supported ingress classes and controllers for the cluster
// allowedOnly set to true will hide globally disallowed ingresscontrollers
export async function getIngressControllerClassMap({
environmentId,
namespace,
allowedOnly,
}: {
environmentId: EnvironmentId;
namespace?: string;
allowedOnly?: boolean;
}) {
try {
const { data: controllerMaps } = await axios.get<
IngressControllerClassMap[]
>(
buildUrl(environmentId, namespace),
allowedOnly ? { params: { allowedOnly: true } } : undefined
);
return controllerMaps;
} catch (e) {
throw new PortainerError('Unable to get ingress controllers.', e as Error);
}
}
// get all supported ingress classes and controllers for the cluster
export async function updateIngressControllerClassMap(
environmentId: EnvironmentId,
ingressControllerClassMap: IngressControllerClassMap[],
namespace?: string
) {
try {
const { data: controllerMaps } = await axios.put<
IngressControllerClassMap[]
>(buildUrl(environmentId, namespace), ingressControllerClassMap);
return controllerMaps;
} catch (e) {
throw new PortainerError(
'Unable to update ingress controllers.',
e as Error
);
}
}
function buildUrl(environmentId: EnvironmentId, namespace?: string) {
let url = `kubernetes/${environmentId}/`;
if (namespace) {
url += `namespaces/${namespace}/`;
}
url += 'ingresscontrollers';
return url;
}

View File

@ -0,0 +1,11 @@
export * from './v1IngressClass';
export * from './v1ObjectMeta';
export type KubernetesApiListResponse<T> = {
apiVersion: string;
kind: string;
items: T;
metadata: {
resourceVersion?: string;
};
};

View File

@ -0,0 +1,48 @@
import { V1ObjectMeta } from './v1ObjectMeta';
/**
* IngressClassParametersReference identifies an API object. This can be used to specify a cluster or namespace-scoped resource.
*/
type V1IngressClassParametersReference = {
/**
* APIGroup is the group for the resource being referenced. If APIGroup is not specified, the specified Kind must be in the core API group. For any other third-party types, APIGroup is required.
*/
apiGroup?: string;
/**
* Kind is the type of resource being referenced.
*/
kind: string;
/**
* Name is the name of resource being referenced.
*/
name: string;
/**
* Namespace is the namespace of the resource being referenced. This field is required when scope is set to \"Namespace\" and must be unset when scope is set to \"Cluster\".
*/
namespace?: string;
/**
* Scope represents if this refers to a cluster or namespace scoped resource. This may be set to \"Cluster\" (default) or \"Namespace\".
*/
scope?: string;
};
type V1IngressClassSpec = {
controller?: string;
parameters?: V1IngressClassParametersReference;
};
/**
* IngressClass represents the class of the Ingress, referenced by the Ingress Spec. The `ingressclass.kubernetes.io/is-default-class` annotation can be used to indicate that an IngressClass should be considered default. When a single IngressClass resource has this annotation set to true, new Ingress resources without a class specified will be assigned this default class.
*/
export type V1IngressClass = {
/**
* APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
*/
apiVersion?: string;
/**
* Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
*/
kind?: string;
metadata?: V1ObjectMeta;
spec?: V1IngressClassSpec;
};

View File

@ -0,0 +1,33 @@
// type definitions taken from https://github.com/kubernetes-client/javascript/blob/master/src/gen/model/v1ObjectMeta.ts
// and simplified to only include the types we need
export type V1ObjectMeta = {
/**
* Annotations is an unstructured key value map stored with a resource that may be set by external tools to store and retrieve arbitrary metadata. They are not queryable and should be preserved when modifying objects. More info: http://kubernetes.io/docs/user-guide/annotations
*/
annotations?: { [key: string]: string };
/**
* Map of string keys and values that can be used to organize and categorize (scope and select) objects. May match selectors of replication controllers and services. More info: http://kubernetes.io/docs/user-guide/labels
*/
labels?: { [key: string]: string };
/**
* Deprecated: ClusterName is a legacy field that was always cleared by the system and never used; it will be removed completely in 1.25. The name in the go struct is changed to help clients detect accidental use.
*/
clusterName?: string;
/**
* Name must be unique within a namespace. Is required when creating resources, although some resources may allow a client to request the generation of an appropriate name automatically. Name is primarily intended for creation idempotence and configuration definition. Cannot be updated. More info: http://kubernetes.io/docs/user-guide/identifiers#names
*/
name?: string;
/**
* Namespace defines the space within which each name must be unique. An empty namespace is equivalent to the \"default\" namespace, but \"default\" is the canonical representation. Not all objects are required to be scoped to a namespace - the value of this field for those objects will be empty. Must be a DNS_LABEL. Cannot be updated. More info: http://kubernetes.io/docs/user-guide/namespaces
*/
namespace?: string;
/**
* An opaque value that represents the internal version of this object that can be used by clients to determine when objects have changed. May be used for optimistic concurrency, change detection, and the watch operation on a resource or set of resources. Clients must treat these values as opaque and passed unmodified back to the server. They may only be valid for a particular resource or set of resources. Populated by the system. Read-only. Value must be treated as opaque by clients and . More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#concurrency-control-and-consistency
*/
resourceVersion?: string;
/**
* UID is the unique in time and space value for this object. It is typically generated by the server on successful creation of a resource and is not allowed to change on PUT operations. Populated by the system. Read-only. More info: http://kubernetes.io/docs/user-guide/identifiers#uids
*/
uid?: string;
};