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"`
Availability bool `json:"Availability"`
New bool `json:"New"`
Used bool `json:"Used"`
}
K8sIngressControllers []K8sIngressController

View File

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

View File

@ -6,7 +6,11 @@ import (
)
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()
if err != nil {
return err
@ -45,3 +49,20 @@ func (m *Migrator) migrateDBVersionToDB70() error {
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": {
"Configuration": {
"EnableResourceOverCommit": false,
"IngressAvailabilityPerNamespace": true,
"IngressClasses": null,
"ResourceOverCommitPercentage": 0,
"RestrictDefaultNamespace": false,

View File

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

View File

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

View File

@ -1,7 +1,6 @@
package kubernetes
import (
"fmt"
"net/http"
httperror "github.com/portainer/libhttp/error"
@ -15,35 +14,39 @@ import (
func (handler *Handler) getKubernetesIngressControllers(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id")
if err != nil {
return &httperror.HandlerError{
StatusCode: http.StatusBadRequest,
Message: "Invalid environment identifier route variable",
Err: err,
}
return httperror.BadRequest(
"Invalid environment identifier route variable",
err,
)
}
endpoint, err := handler.dataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID))
if err == portainerDsErrors.ErrObjectNotFound {
return &httperror.HandlerError{
StatusCode: http.StatusNotFound,
Message: "Unable to find an environment with the specified identifier inside the database",
Err: err,
}
return httperror.NotFound(
"Unable to find an environment with the specified identifier inside the database",
err,
)
} else if err != nil {
return &httperror.HandlerError{
StatusCode: http.StatusInternalServerError,
Message: "Unable to find an environment with the specified identifier inside the database",
Err: err,
}
return httperror.InternalServerError(
"Unable to find an environment with the specified identifier inside the database",
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)
if err != nil {
return &httperror.HandlerError{
StatusCode: http.StatusInternalServerError,
Message: "Unable to create Kubernetes client",
Err: err,
}
return httperror.InternalServerError(
"Unable to create Kubernetes client",
err,
)
}
controllers := cli.GetIngressControllers()
@ -66,11 +69,44 @@ func (handler *Handler) getKubernetesIngressControllers(w http.ResponseWriter, r
}
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)
}
@ -78,80 +114,84 @@ func (handler *Handler) getKubernetesIngressControllers(w http.ResponseWriter, r
func (handler *Handler) getKubernetesIngressControllersByNamespace(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id")
if err != nil {
return &httperror.HandlerError{
StatusCode: http.StatusBadRequest,
Message: "Invalid environment identifier route variable",
Err: err,
}
return httperror.BadRequest(
"Invalid environment identifier route variable",
err,
)
}
endpoint, err := handler.dataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID))
if err == portainerDsErrors.ErrObjectNotFound {
return &httperror.HandlerError{
StatusCode: http.StatusNotFound,
Message: "Unable to find an environment with the specified identifier inside the database",
Err: err,
}
return httperror.NotFound(
"Unable to find an environment with the specified identifier inside the database",
err,
)
} else if err != nil {
return &httperror.HandlerError{
StatusCode: http.StatusInternalServerError,
Message: "Unable to find an environment with the specified identifier inside the database",
Err: err,
}
return httperror.InternalServerError(
"Unable to find an environment with the specified identifier inside the database",
err,
)
}
namespace, err := request.RetrieveRouteVariableValue(r, "namespace")
if err != nil {
return &httperror.HandlerError{
StatusCode: http.StatusBadRequest,
Message: "Invalid namespace identifier route variable",
Err: err,
}
return httperror.BadRequest(
"Invalid namespace identifier route variable",
err,
)
}
cli, err := handler.kubernetesClientFactory.GetKubeClient(endpoint)
if err != nil {
return &httperror.HandlerError{
StatusCode: http.StatusInternalServerError,
Message: "Unable to create Kubernetes client",
Err: err,
}
}
controllers := cli.GetIngressControllers()
cli := handler.KubernetesClient
currentControllers := cli.GetIngressControllers()
existingClasses := endpoint.Kubernetes.Configuration.IngressClasses
for i := range controllers {
controllers[i].Availability = true
controllers[i].New = true
var updatedClasses []portainer.KubernetesIngressClassConfig
var controllers models.K8sIngressControllers
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
// namespace.
for _, a := range existingClasses {
if controllers[i].ClassName != a.Name {
for _, existingClass := range existingClasses {
if currentControllers[i].ClassName != existingClass.Name {
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!
if !a.Blocked {
continue
}
globallyblocked = existingClass.GloballyBlocked
// Global blocks.
if len(a.BlockedNamespaces) == 0 {
controllers[i].Availability = false
continue
}
// Also check the current namespace.
for _, ns := range a.BlockedNamespaces {
// Check if the current namespace is blocked.
for _, ns := range existingClass.BlockedNamespaces {
if namespace == ns {
controllers[i].Availability = false
currentControllers[i].Availability = false
}
}
}
// TODO: Update existingClasses to take care of New and remove no longer
// existing classes.
if !globallyblocked {
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)
}
@ -159,167 +199,205 @@ func (handler *Handler) getKubernetesIngressControllersByNamespace(w http.Respon
func (handler *Handler) updateKubernetesIngressControllers(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id")
if err != nil {
return &httperror.HandlerError{
StatusCode: http.StatusBadRequest,
Message: "Invalid environment identifier route variable",
Err: err,
}
return httperror.BadRequest(
"Invalid environment identifier route variable",
err,
)
}
endpoint, err := handler.dataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID))
if err == portainerDsErrors.ErrObjectNotFound {
return &httperror.HandlerError{
StatusCode: http.StatusNotFound,
Message: "Unable to find an environment with the specified identifier inside the database",
Err: err,
}
return httperror.NotFound(
"Unable to find an environment with the specified identifier inside the database",
err,
)
} else if err != nil {
return &httperror.HandlerError{
StatusCode: http.StatusInternalServerError,
Message: "Unable to find an environment with the specified identifier inside the database",
Err: err,
}
return httperror.InternalServerError(
"Unable to find an environment with the specified identifier inside the database",
err,
)
}
var payload models.K8sIngressControllers
err = request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
return &httperror.HandlerError{
StatusCode: http.StatusBadRequest,
Message: "Invalid request payload",
Err: err,
}
return httperror.BadRequest(
"Invalid request payload",
err,
)
}
classes := endpoint.Kubernetes.Configuration.IngressClasses
for _, p := range payload {
for i := range classes {
if p.ClassName == classes[i].Name {
classes[i].Blocked = !p.Availability
cli, err := handler.kubernetesClientFactory.GetKubeClient(endpoint)
if err != nil {
return httperror.InternalServerError(
"Unable to create Kubernetes client",
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(
portainer.EndpointID(endpointID),
endpoint,
)
if err != nil {
return &httperror.HandlerError{
StatusCode: http.StatusInternalServerError,
Message: "Unable to update the BlockedIngressClasses inside the database",
Err: err,
}
return httperror.InternalServerError(
"Unable to update the BlockedIngressClasses inside the database",
err,
)
}
return nil
return response.Empty(w)
}
func (handler *Handler) updateKubernetesIngressControllersByNamespace(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id")
if err != nil {
return &httperror.HandlerError{
StatusCode: http.StatusBadRequest,
Message: "Invalid environment identifier route variable",
Err: err,
}
return httperror.BadRequest(
"Invalid environment identifier route variable",
err,
)
}
endpoint, err := handler.dataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID))
if err == portainerDsErrors.ErrObjectNotFound {
return &httperror.HandlerError{
StatusCode: http.StatusNotFound,
Message: "Unable to find an environment with the specified identifier inside the database",
Err: err,
}
return httperror.NotFound(
"Unable to find an environment with the specified identifier inside the database",
err,
)
} else if err != nil {
return &httperror.HandlerError{
StatusCode: http.StatusInternalServerError,
Message: "Unable to find an environment with the specified identifier inside the database",
Err: err,
}
return httperror.InternalServerError(
"Unable to find an environment with the specified identifier inside the database",
err,
)
}
namespace, err := request.RetrieveRouteVariableValue(r, "namespace")
if err != nil {
return &httperror.HandlerError{
StatusCode: http.StatusBadRequest,
Message: "Invalid namespace identifier route variable",
Err: err,
}
return httperror.BadRequest(
"Invalid namespace identifier route variable",
err,
)
}
var payload models.K8sIngressControllers
err = request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
return &httperror.HandlerError{
StatusCode: http.StatusBadRequest,
Message: "Invalid request payload",
Err: err,
}
return httperror.BadRequest(
"Invalid request payload",
err,
)
}
classes := endpoint.Kubernetes.Configuration.IngressClasses
existingClasses := endpoint.Kubernetes.Configuration.IngressClasses
var updatedClasses []portainer.KubernetesIngressClassConfig
PayloadLoop:
for _, p := range payload {
for i := range classes {
if p.ClassName == classes[i].Name {
if p.Availability == true {
classes[i].Blocked = false
classes[i].BlockedNamespaces = []string{}
continue PayloadLoop
}
for _, existingClass := range existingClasses {
if p.ClassName != existingClass.Name {
updatedClasses = append(updatedClasses, existingClass)
continue
}
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
// namespace. First, check if it's already in the
// BlockedNamespaces and if not we append it.
classes[i].Blocked = true
for _, ns := range classes[i].BlockedNamespaces {
if namespace == ns {
continue PayloadLoop
// Handle "allow"
if p.Availability == true {
// remove the namespace from the list of blocked namespaces
// in the existingClass.
for _, blockedNS := range existingClass.BlockedNamespaces {
if blockedNS != namespace {
updatedClass.BlockedNamespaces = append(updatedClass.BlockedNamespaces, blockedNS)
}
}
classes[i].BlockedNamespaces = append(
classes[i].BlockedNamespaces,
namespace,
)
updatedClasses = append(updatedClasses, existingClass)
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(
portainer.EndpointID(endpointID),
endpoint,
)
if err != nil {
return &httperror.HandlerError{
StatusCode: http.StatusInternalServerError,
Message: "Unable to update the BlockedIngressClasses inside the database",
Err: err,
}
return httperror.InternalServerError(
"Unable to update the BlockedIngressClasses inside the database",
err,
)
}
return nil
return response.Empty(w)
}
func (handler *Handler) getKubernetesIngresses(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
namespace, err := request.RetrieveRouteVariableValue(r, "namespace")
if err != nil {
return &httperror.HandlerError{
StatusCode: http.StatusBadRequest,
Message: "Invalid namespace identifier route variable",
Err: err,
}
return httperror.BadRequest(
"Invalid namespace identifier route variable",
err,
)
}
cli := handler.KubernetesClient
ingresses, err := cli.GetIngresses(namespace)
if err != nil {
return &httperror.HandlerError{
StatusCode: http.StatusInternalServerError,
Message: "Unable to retrieve nodes limits",
Err: err,
}
return httperror.InternalServerError(
"Unable to retrieve nodes limits",
err,
)
}
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 {
namespace, err := request.RetrieveRouteVariableValue(r, "namespace")
if err != nil {
return &httperror.HandlerError{
StatusCode: http.StatusBadRequest,
Message: "Invalid namespace identifier route variable",
Err: err,
}
return httperror.BadRequest(
"Invalid namespace identifier route variable",
err,
)
}
var payload models.K8sIngressInfo
err = request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
return &httperror.HandlerError{
StatusCode: http.StatusBadRequest,
Message: "Invalid request payload",
Err: err,
}
return httperror.BadRequest(
"Invalid request payload",
err,
)
}
cli := handler.KubernetesClient
err = cli.CreateIngress(namespace, payload)
if err != nil {
return &httperror.HandlerError{
StatusCode: http.StatusInternalServerError,
Message: "Unable to retrieve nodes limits",
Err: err,
}
return httperror.InternalServerError(
"Unable to retrieve nodes limits",
err,
)
}
return nil
return response.Empty(w)
}
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)
if err != nil {
return &httperror.HandlerError{
StatusCode: http.StatusInternalServerError,
Message: "Unable to retrieve nodes limits",
Err: err,
}
return httperror.InternalServerError(
"Unable to retrieve nodes limits",
err,
)
}
return nil
return response.Empty(w)
}
func (handler *Handler) updateKubernetesIngress(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
namespace, err := request.RetrieveRouteVariableValue(r, "namespace")
if err != nil {
return &httperror.HandlerError{
StatusCode: http.StatusBadRequest,
Message: "Invalid namespace identifier route variable",
Err: err,
}
return httperror.BadRequest(
"Invalid namespace identifier route variable",
err,
)
}
var payload models.K8sIngressInfo
err = request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
return &httperror.HandlerError{
StatusCode: http.StatusBadRequest,
Message: "Invalid request payload",
Err: err,
}
return httperror.BadRequest(
"Invalid request payload",
err,
)
}
cli := handler.KubernetesClient
err = cli.UpdateIngress(namespace, payload)
if err != nil {
return &httperror.HandlerError{
StatusCode: http.StatusInternalServerError,
Message: "Unable to retrieve nodes limits",
Err: err,
}
return httperror.InternalServerError(
"Unable to retrieve nodes limits",
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()
if err != nil {
return &httperror.HandlerError{
StatusCode: http.StatusInternalServerError,
Message: "Unable to retrieve nodes limits",
Err: err,
}
return httperror.InternalServerError(
"Unable to retrieve nodes limits",
err,
)
}
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 {
cli := handler.KubernetesClient
var payload models.K8sNamespaceInfo
var payload models.K8sNamespaceDetails
err := request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
return &httperror.HandlerError{
StatusCode: http.StatusBadRequest,
Message: "Invalid request payload",
Err: err,
}
return httperror.BadRequest(
"Invalid request payload",
err,
)
}
err = cli.CreateNamespace(payload)
if err != nil {
return &httperror.HandlerError{
StatusCode: http.StatusInternalServerError,
Message: "Unable to retrieve nodes limits",
Err: err,
}
return httperror.InternalServerError(
"Unable to retrieve nodes limits",
err,
)
}
return nil
}
@ -53,20 +50,18 @@ func (handler *Handler) deleteKubernetesNamespaces(w http.ResponseWriter, r *htt
namespace, err := request.RetrieveRouteVariableValue(r, "namespace")
if err != nil {
return &httperror.HandlerError{
StatusCode: http.StatusBadRequest,
Message: "Invalid namespace identifier route variable",
Err: err,
}
return httperror.BadRequest(
"Invalid namespace identifier route variable",
err,
)
}
err = cli.DeleteNamespace(namespace)
if err != nil {
return &httperror.HandlerError{
StatusCode: http.StatusInternalServerError,
Message: "Unable to retrieve nodes limits",
Err: err,
}
return httperror.InternalServerError(
"Unable to retrieve nodes limits",
err,
)
}
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 {
cli := handler.KubernetesClient
var payload models.K8sNamespaceInfo
var payload models.K8sNamespaceDetails
err := request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
return &httperror.HandlerError{

View File

@ -12,21 +12,19 @@ import (
func (handler *Handler) getKubernetesServices(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
namespace, err := request.RetrieveRouteVariableValue(r, "namespace")
if err != nil {
return &httperror.HandlerError{
StatusCode: http.StatusBadRequest,
Message: "Invalid namespace identifier route variable",
Err: err,
}
return httperror.BadRequest(
"Invalid namespace identifier route variable",
err,
)
}
cli := handler.KubernetesClient
services, err := cli.GetServices(namespace)
if err != nil {
return &httperror.HandlerError{
StatusCode: http.StatusInternalServerError,
Message: "Unable to retrieve services",
Err: err,
}
return httperror.InternalServerError(
"Unable to retrieve services",
err,
)
}
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 {
namespace, err := request.RetrieveRouteVariableValue(r, "namespace")
if err != nil {
return &httperror.HandlerError{
StatusCode: http.StatusBadRequest,
Message: "Invalid namespace identifier route variable",
Err: err,
}
return httperror.BadRequest(
"Invalid namespace identifier route variable",
err,
)
}
var payload models.K8sServiceInfo
err = request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
return &httperror.HandlerError{
StatusCode: http.StatusBadRequest,
Message: "Invalid request payload",
Err: err,
}
return httperror.BadRequest(
"Invalid request payload",
err,
)
}
cli := handler.KubernetesClient
err = cli.CreateService(namespace, payload)
if err != nil {
return &httperror.HandlerError{
StatusCode: http.StatusInternalServerError,
Message: "Unable to retrieve nodes limits",
Err: err,
}
return httperror.InternalServerError(
"Unable to retrieve nodes limits",
err,
)
}
return nil
}
@ -70,20 +65,18 @@ func (handler *Handler) deleteKubernetesServices(w http.ResponseWriter, r *http.
var payload models.K8sServiceDeleteRequests
err := request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
return &httperror.HandlerError{
StatusCode: http.StatusBadRequest,
Message: "Invalid request payload",
Err: err,
}
return httperror.BadRequest(
"Invalid request payload",
err,
)
}
err = cli.DeleteServices(payload)
if err != nil {
return &httperror.HandlerError{
StatusCode: http.StatusInternalServerError,
Message: "Unable to retrieve nodes limits",
Err: err,
}
return httperror.InternalServerError(
"Unable to retrieve nodes limits",
err,
)
}
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 {
namespace, err := request.RetrieveRouteVariableValue(r, "namespace")
if err != nil {
return &httperror.HandlerError{
StatusCode: http.StatusBadRequest,
Message: "Invalid namespace identifier route variable",
Err: err,
}
return httperror.BadRequest(
"Invalid namespace identifier route variable",
err,
)
}
var payload models.K8sServiceInfo
err = request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
return &httperror.HandlerError{
StatusCode: http.StatusBadRequest,
Message: "Invalid request payload",
Err: err,
}
return httperror.BadRequest(
"Invalid request payload",
err,
)
}
cli := handler.KubernetesClient
err = cli.UpdateService(namespace, payload)
if err != nil {
return &httperror.HandlerError{
StatusCode: http.StatusInternalServerError,
Message: "Unable to retrieve nodes limits",
Err: err,
}
return httperror.InternalServerError(
"Unable to retrieve nodes limits",
err,
)
}
return nil
}

View File

@ -20,10 +20,34 @@ func (kcl *KubeClient) GetIngressControllers() models.K8sIngressControllers {
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 {
var controller models.K8sIngressController
controller.Name = class.Spec.Controller
controller.ClassName = class.Name
// If the class is used mark it as such.
if _, ok := usedClasses[class.Name]; ok {
controller.Used = true
}
switch {
case strings.Contains(controller.Name, "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.
func (kcl *KubeClient) CreateNamespace(info models.K8sNamespaceInfo) error {
func (kcl *KubeClient) CreateNamespace(info models.K8sNamespaceDetails) error {
client := kcl.cli.CoreV1().Namespaces()
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.
func (kcl *KubeClient) UpdateNamespace(info models.K8sNamespaceInfo) error {
func (kcl *KubeClient) UpdateNamespace(info models.K8sNamespaceDetails) error {
client := kcl.cli.CoreV1().Namespaces()
var ns v1.Namespace

View File

@ -546,13 +546,14 @@ type (
// KubernetesConfiguration represents the configuration of a Kubernetes environment(endpoint)
KubernetesConfiguration struct {
UseLoadBalancer bool `json:"UseLoadBalancer"`
UseServerMetrics bool `json:"UseServerMetrics"`
EnableResourceOverCommit bool `json:"EnableResourceOverCommit"`
ResourceOverCommitPercentage int `json:"ResourceOverCommitPercentage"`
StorageClasses []KubernetesStorageClassConfig `json:"StorageClasses"`
IngressClasses []KubernetesIngressClassConfig `json:"IngressClasses"`
RestrictDefaultNamespace bool `json:"RestrictDefaultNamespace"`
UseLoadBalancer bool `json:"UseLoadBalancer"`
UseServerMetrics bool `json:"UseServerMetrics"`
EnableResourceOverCommit bool `json:"EnableResourceOverCommit"`
ResourceOverCommitPercentage int `json:"ResourceOverCommitPercentage"`
StorageClasses []KubernetesStorageClassConfig `json:"StorageClasses"`
IngressClasses []KubernetesIngressClassConfig `json:"IngressClasses"`
RestrictDefaultNamespace bool `json:"RestrictDefaultNamespace"`
IngressAvailabilityPerNamespace bool `json:"IngressAvailabilityPerNamespace"`
}
// KubernetesStorageClassConfig represents a Kubernetes Storage Class configuration
@ -567,7 +568,7 @@ type (
KubernetesIngressClassConfig struct {
Name string `json:"Name"`
Type string `json:"Type"`
Blocked bool `json:"Blocked"`
GloballyBlocked bool `json:"Blocked"`
BlockedNamespaces []string `json:"BlockedNamespaces"`
}
@ -1349,11 +1350,14 @@ type (
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)
CreateNamespace(info models.K8sNamespaceInfo) error
UpdateNamespace(info models.K8sNamespaceInfo) error
HasStackName(namespace string, stackName string) (bool, error)
NamespaceAccessPoliciesDeleteNamespace(namespace string) error
CreateNamespace(info models.K8sNamespaceDetails) error
UpdateNamespace(info models.K8sNamespaceDetails) error
GetNamespaces() (map[string]K8sNamespaceInfo, error)
DeleteNamespace(namespace string) error
GetConfigMapsAndSecrets(namespace string) ([]models.K8sConfigMapOrSecret, error)
GetIngressControllers() models.K8sIngressControllers
CreateIngress(namespace string, info models.K8sIngressInfo) error
UpdateIngress(namespace string, info models.K8sIngressInfo) error
GetIngresses(namespace string) ([]models.K8sIngressInfo, error)
@ -1362,10 +1366,6 @@ type (
UpdateService(namespace string, service models.K8sServiceInfo) error
GetServices(namespace string) ([]models.K8sServiceInfo, error)
DeleteServices(reqs models.K8sServiceDeleteRequests) error
GetIngressControllers() models.K8sIngressControllers
HasStackName(namespace string, stackName string) (bool, error)
NamespaceAccessPoliciesDeleteNamespace(namespace string) error
GetNodesLimits() (K8sNodesLimits, error)
GetNamespaceAccessPolicies() (map[string]K8sNamespaceAccessPolicy, error)
UpdateNamespaceAccessPolicies(accessPolicies map[string]K8sNamespaceAccessPolicy) error

View File

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

View File

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

View File

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

View File

@ -35,124 +35,35 @@
<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="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>
</label>
</div>
</div>
<div class="form-group">
<div class="col-sm-12 text-muted small mt-4">
<p> Configuring ingress controllers will allow users to expose application they deploy over a HTTP route. </p>
<p class="mt-1 vertical-center">
<pr-icon icon="'alert-circle'" mode="'warning'" feather="true"></pr-icon>
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.
</p>
</div>
</div>
<ingress-class-datatable
on-change-availability="(ctrl.onChangeAvailability)"
ingress-controllers="ctrl.originalIngressControllers"
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.'"
no-ingress-controller-label="'No supported ingress controllers found.'"
view="'cluster'"
></ingress-class-datatable>
<div class="form-group">
<div class="col-sm-12">
<label class="control-label text-left">Ingress controller</label>
<span class="label label-default interactive vertical-center" style="margin-left: 10px" ng-click="ctrl.addIngressClass()" data-cy="kubeSetup-congifIngressButton">
<pr-icon icon="'plus'" feather="true" size="'sm'" class="vertical-center"></pr-icon> configure ingress controller
</span>
</div>
<div class="col-sm-12 form-inline" style="margin-top: 10px">
<div ng-repeat-start="ingressClass in ctrl.formValues.IngressClasses" style="margin-top: 2px">
<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
"
<por-switch-field
checked="ctrl.formValues.IngressAvailabilityPerNamespace"
name="'ingressAvailabilityPerNamespace'"
label="'Configure ingress controller availability per namespace'"
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)"
label-class="'col-sm-5 col-lg-4 px-0 !m-0'"
switch-class="'col-sm-8'"
>
<div class="col-sm-7 input-group">
<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>
</por-switch-field>
</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 -->
<div class="col-sm-12 form-section-title"> Change Window Settings </div>
@ -163,7 +74,7 @@
name="'disableSysctlSettingForRegularUsers'"
label="'Enable Change Window'"
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)"
label-class="'col-sm-5 col-lg-4 px-0 !m-0'"
switch-class="'col-sm-8 text-muted'"

View File

@ -2,12 +2,12 @@ import _ from 'lodash-es';
import angular from 'angular';
import { KubernetesStorageClass, KubernetesStorageClassAccessPolicies } from 'Kubernetes/models/storage-class/models';
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 KubernetesNamespaceHelper from 'Kubernetes/helpers/namespaceHelper';
import { FeatureId } from '@/portainer/feature-flags/enums';
import { getIngressControllerClassMap, updateIngressControllerClassMap } from '@/react/kubernetes/cluster/ingressClass/utils';
class KubernetesConfigureController {
/* #region CONSTRUCTOR */
@ -41,10 +41,15 @@ class KubernetesConfigureController {
this.onInit = this.onInit.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.limitedFeatureAutoWindow = FeatureId.HIDE_AUTO_UPDATE_WINDOW;
this.onToggleAutoUpdate = this.onToggleAutoUpdate.bind(this);
this.onChangeAvailability = this.onChangeAvailability.bind(this);
this.onChangeEnableResourceOverCommit = this.onChangeEnableResourceOverCommit.bind(this);
this.onToggleIngressAvailabilityPerNamespace = this.onToggleIngressAvailabilityPerNamespace.bind(this);
this.onChangeStorageClassAccessMode = this.onChangeStorageClassAccessMode.bind(this);
}
/* #endregion */
@ -66,48 +71,23 @@ class KubernetesConfigureController {
/* #endregion */
/* #region INGRESS CLASSES UI MANAGEMENT */
addIngressClass() {
this.formValues.IngressClasses.push(new KubernetesIngressClass());
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();
onChangeAvailability(controllerClassMap) {
this.ingressControllers = controllerClassMap;
}
hasTraefikIngress() {
return _.find(this.formValues.IngressClasses, { Type: this.IngressClassTypes.TRAEFIK });
}
onToggleIngressAvailabilityPerNamespace() {
this.$scope.$evalAsync(() => {
this.formValues.IngressAvailabilityPerNamespace = !this.formValues.IngressAvailabilityPerNamespace;
});
}
/* #endregion */
/* #region RESOURCES AND METRICS */
onChangeEnableResourceOverCommit(enabled) {
this.$scope.$evalAsync(() => {
this.formValues.EnableResourceOverCommit = enabled;
@ -117,13 +97,19 @@ class KubernetesConfigureController {
});
}
/* #endregion */
/* #region CONFIGURE */
assignFormValuesToEndpoint(endpoint, storageClasses, ingressClasses) {
endpoint.Kubernetes.Configuration.StorageClasses = storageClasses;
endpoint.Kubernetes.Configuration.UseLoadBalancer = this.formValues.UseLoadBalancer;
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.RestrictDefaultNamespace = this.formValues.RestrictDefaultNamespace;
endpoint.Kubernetes.Configuration.IngressAvailabilityPerNamespace = this.formValues.IngressAvailabilityPerNamespace;
endpoint.ChangeWindow = this.state.autoUpdateSettings;
}
transformFormValues() {
@ -150,11 +136,9 @@ class KubernetesConfigureController {
async removeIngressesAcrossNamespaces() {
const ingressesToDel = _.filter(this.formValues.IngressClasses, { NeedsDeletion: true });
if (!ingressesToDel.length) {
return;
}
const promises = [];
const oldEndpointID = this.EndpointProvider.endpointID();
this.EndpointProvider.setEndpointID(this.endpoint.Id);
@ -213,7 +197,9 @@ class KubernetesConfigureController {
this.assignFormValuesToEndpoint(this.endpoint, storageClasses, ingressClasses);
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 oldStorageClass = _.find(this.oldStorageClasses, { Name: storageClass.Name });
if (oldStorageClass) {
@ -291,19 +277,31 @@ class KubernetesConfigureController {
isServerRunning: false,
userClick: false,
},
timeZone: '',
isSaving: false,
};
this.formValues = {
UseLoadBalancer: false,
UseServerMetrics: false,
EnableResourceOverCommit: true,
ResourceOverCommitPercentage: 20,
IngressClasses: [],
RestrictDefaultNamespace: false,
enableAutoUpdateTimeWindow: false,
IngressAvailabilityPerNamespace: false,
};
try {
this.availableAccessModes = new KubernetesStorageClassAccessPolicies();
[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) => {
const storage = _.find(this.endpoint.Kubernetes.Configuration.StorageClasses, (sc) => sc.Name === item.Name);
if (storage) {
@ -316,12 +314,15 @@ class KubernetesConfigureController {
this.formValues.UseLoadBalancer = this.endpoint.Kubernetes.Configuration.UseLoadBalancer;
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.IngressClasses = _.map(this.endpoint.Kubernetes.Configuration.IngressClasses, (ic) => {
ic.IsNew = false;
ic.NeedsDeletion = false;
return ic;
});
this.formValues.IngressAvailabilityPerNamespace = this.endpoint.Kubernetes.Configuration.IngressAvailabilityPerNamespace;
this.oldFormValues = Object.assign({}, this.formValues);
} catch (err) {
@ -329,12 +330,48 @@ class KubernetesConfigureController {
} finally {
this.state.viewReady = true;
}
window.addEventListener('beforeunload', this.onBeforeOnload);
}
$onInit() {
return this.$async(this.onInit);
}
/* #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;

View File

@ -180,214 +180,17 @@
</div>
<!-- #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 -->
<div ng-if="$ctrl.state.canUseIngress">
<div class="col-sm-12 form-section-title"> Ingresses </div>
<div ng-if="$ctrl.state.ingressAvailabilityPerNamespace">
<!-- #region INGRESSES -->
<div class="form-group" ng-if="$ctrl.formValues.IngressClasses.length === 0">
<div class="col-sm-12 small text-muted">
The ingress feature must be enabled in the
<a ui-sref="kubernetes.cluster.setup">environment configuration view</a> to be able to register ingresses inside this namespace.
</div>
</div>
<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="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>
<div class="col-sm-12 form-section-title"> Networking </div>
<ingress-class-datatable
ng-if="$ctrl.state.ingressAvailabilityPerNamespace"
on-change-availability="($ctrl.onChangeIngressControllerAvailability)"
ingress-controllers="$ctrl.ingressControllers"
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>
<!-- #endregion -->
</div>
@ -422,6 +225,25 @@
</div>
<!-- #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 -->
<kubernetes-summary-view ng-if="resourcePoolCreationForm.$valid && !$ctrl.isCreateButtonDisabled()" form-values="$ctrl.formValues"></kubernetes-summary-view>
<!-- !summary -->

View File

@ -2,20 +2,12 @@ import _ from 'lodash-es';
import filesizeParser from 'filesize-parser';
import { KubernetesResourceQuotaDefaults } from 'Kubernetes/models/resource-quota/models';
import KubernetesResourceReservationHelper from 'Kubernetes/helpers/resourceReservationHelper';
import {
KubernetesResourcePoolFormValues,
KubernetesResourcePoolIngressClassAnnotationFormValue,
KubernetesResourcePoolIngressClassHostFormValue,
KubernetesResourcePoolNginxRewriteAnnotationFormValue,
KubernetesResourcePoolNginxUseregexAnnotationFormValue,
KubernetesResourcePoolTraefikRewriteAnnotationFormValue,
} from 'Kubernetes/models/resource-pool/formValues';
import { KubernetesResourcePoolFormValues, KubernetesResourcePoolIngressClassHostFormValue } from 'Kubernetes/models/resource-pool/formValues';
import { KubernetesIngressConverter } from 'Kubernetes/ingress/converter';
import KubernetesFormValidationHelper from 'Kubernetes/helpers/formValidationHelper';
import { KubernetesFormValidationReferences } from 'Kubernetes/models/application/formValues';
import { KubernetesIngressClassTypes } from 'Kubernetes/ingress/constants';
import { FeatureId } from '@/portainer/feature-flags/enums';
import { getIngressControllerClassMap, updateIngressControllerClassMap } from '@/react/kubernetes/cluster/ingressClass/utils';
class KubernetesCreateResourcePoolController {
/* #region CONSTRUCTOR */
@ -39,10 +31,17 @@ class KubernetesCreateResourcePoolController {
this.onToggleStorageQuota = this.onToggleStorageQuota.bind(this);
this.onToggleLoadBalancerQuota = this.onToggleLoadBalancerQuota.bind(this);
this.onToggleResourceQuota = this.onToggleResourceQuota.bind(this);
this.onChangeIngressControllerAvailability = this.onChangeIngressControllerAvailability.bind(this);
this.onRegistriesChange = this.onRegistriesChange.bind(this);
}
/* #endregion */
onRegistriesChange(registries) {
return this.$scope.$evalAsync(() => {
this.formValues.Registries = registries;
});
}
onToggleStorageQuota(storageClassName, enabled) {
this.$scope.$evalAsync(() => {
this.formValues.StorageClasses = this.formValues.StorageClasses.map((sClass) => (sClass.Name !== storageClassName ? sClass : { ...sClass, Selected: enabled }));
@ -61,74 +60,9 @@ class KubernetesCreateResourcePoolController {
});
}
onChangeIngressHostname() {
const state = this.state.duplicates.ingressHosts;
const hosts = _.flatMap(this.formValues.IngressClasses, 'Hosts');
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);
/* #region INGRESS MANAGEMENT */
onChangeIngressControllerAvailability(controllerClassMap) {
this.ingressControllers = controllerClassMap;
}
/* #endregion */
@ -175,6 +109,7 @@ class KubernetesCreateResourcePoolController {
this.checkDefaults();
this.formValues.Owner = this.Authentication.getUserDetails().username;
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.$state.go('kubernetes.resourcePools');
} catch (err) {
@ -239,15 +174,21 @@ class KubernetesCreateResourcePoolController {
viewReady: false,
isAlreadyExist: false,
hasPrefixKube: false,
canUseIngress: endpoint.Kubernetes.Configuration.IngressClasses.length,
canUseIngress: false,
duplicates: {
ingressHosts: new KubernetesFormValidationReferences(),
},
isAdmin: this.Authentication.isAdmin(),
ingressAvailabilityPerNamespace: endpoint.Kubernetes.Configuration.IngressAvailabilityPerNamespace,
};
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) => {
this.state.sliderMaxMemory += filesizeParser(item.Memory);
this.state.sliderMaxCpu += item.CPU;

View File

@ -156,193 +156,20 @@
</div>
</div>
<!-- #endregion -->
<div ng-if="ctrl.isAdmin && ctrl.isEditable && ctrl.state.canUseIngress">
<div class="col-sm-12 form-section-title"> Ingresses </div>
<div ng-if="ctrl.isAdmin && ctrl.isEditable && ctrl.state.ingressAvailabilityPerNamespace">
<!-- #region INGRESSES -->
<div class="form-group" ng-if="ctrl.formValues.IngressClasses.length === 0">
<div class="col-sm-12 small text-muted">
The ingress feature must be enabled in the
<a ui-sref="kubernetes.cluster.setup">environment configuration view</a> to be able to register ingresses inside this namespace.
</div>
</div>
<div class="col-sm-12 form-section-title"> Networking </div>
<ingress-class-datatable
ng-if="ctrl.state.ingressAvailabilityPerNamespace"
on-change-availability="(ctrl.onChangeIngressControllerAvailability)"
ingress-controllers="ctrl.ingressControllers"
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 -->
</div>
<!-- #region 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 { KubernetesResourceReservation } from 'Kubernetes/models/resource-reservation/models';
import KubernetesEventHelper from 'Kubernetes/helpers/eventHelper';
import {
KubernetesResourcePoolFormValues,
KubernetesResourcePoolIngressClassAnnotationFormValue,
KubernetesResourcePoolIngressClassHostFormValue,
KubernetesResourcePoolNginxRewriteAnnotationFormValue,
KubernetesResourcePoolNginxUseregexAnnotationFormValue,
KubernetesResourcePoolTraefikRewriteAnnotationFormValue,
} from 'Kubernetes/models/resource-pool/formValues';
import { KubernetesResourcePoolFormValues, KubernetesResourcePoolIngressClassHostFormValue } from 'Kubernetes/models/resource-pool/formValues';
import { KubernetesIngressConverter } from 'Kubernetes/ingress/converter';
import { KubernetesFormValidationReferences } from 'Kubernetes/models/application/formValues';
import KubernetesFormValidationHelper from 'Kubernetes/helpers/formValidationHelper';
import { KubernetesIngressClassTypes } from 'Kubernetes/ingress/constants';
import KubernetesResourceQuotaConverter from 'Kubernetes/converters/resourceQuota';
import KubernetesNamespaceHelper from 'Kubernetes/helpers/namespaceHelper';
import { FeatureId } from '@/portainer/feature-flags/enums';
import { updateIngressControllerClassMap, getIngressControllerClassMap } from '@/react/kubernetes/cluster/ingressClass/utils';
class KubernetesResourcePoolController {
/* #region CONSTRUCTOR */
@ -73,35 +67,11 @@ class KubernetesResourcePoolController {
this.getEvents = this.getEvents.bind(this);
this.onToggleLoadBalancersQuota = this.onToggleLoadBalancersQuota.bind(this);
this.onToggleStorageQuota = this.onToggleStorageQuota.bind(this);
this.onChangeIngressControllerAvailability = this.onChangeIngressControllerAvailability.bind(this);
this.onRegistriesChange = this.onRegistriesChange.bind(this);
}
/* #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) {
return this.$scope.$evalAsync(() => {
this.formValues.Registries = registries;
@ -120,55 +90,10 @@ class KubernetesResourcePoolController {
});
}
/* #region INGRESS MANAGEMENT */
onChangeIngressHostname() {
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;
}
});
});
onChangeIngressControllerAvailability(controllerClassMap) {
this.ingressControllers = controllerClassMap;
}
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) {
this.LocalStorage.storeActiveTab('resourcePool', index);
}
@ -219,6 +144,7 @@ class KubernetesResourcePoolController {
try {
this.checkDefaults();
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.$state.reload(this.$state.current);
} catch (err) {
@ -426,11 +352,12 @@ class KubernetesResourcePoolController {
ingressesLoading: true,
viewReady: false,
eventWarningCount: 0,
canUseIngress: this.endpoint.Kubernetes.Configuration.IngressClasses.length,
canUseIngress: false,
useServerMetrics: this.endpoint.Kubernetes.Configuration.UseServerMetrics,
duplicates: {
ingressHosts: new KubernetesFormValidationReferences(),
},
ingressAvailabilityPerNamespace: this.endpoint.Kubernetes.Configuration.IngressAvailabilityPerNamespace,
};
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()]);
this.ingressControllers = [];
if (this.state.ingressAvailabilityPerNamespace) {
this.ingressControllers = await getIngressControllerClassMap({ environmentId: this.endpoint.Id, namespace: name });
}
this.pool = _.find(pools, { Namespace: { Name: name } });
this.formValues = new KubernetesResourcePoolFormValues(KubernetesResourceQuotaDefaults);
this.formValues.Name = this.pool.Namespace.Name;

View File

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

View File

@ -99,7 +99,7 @@
<div class="form-group overflow-auto" ng-if="ctrl.state.showStandardLogin">
<!-- login button -->
<div class="col-sm-12 d-flex">
<div class="col-sm-12 d-flex py-1">
<button
type="submit"
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>>;
isLoading?: boolean;
totalCount?: number;
description?: JSX.Element;
}
export function Datatable<
@ -74,6 +75,7 @@ export function Datatable<
initialTableState = {},
isLoading,
totalCount = dataset.length,
description,
}: Props<D, TSettings>) {
const [searchBarValue, setSearchBarValue] = useSearchBarState(storageKey);
@ -145,6 +147,7 @@ export function Datatable<
label={titleOptions.title}
icon={titleOptions.icon}
featherIcon={titleOptions.featherIcon}
description={description}
>
<SearchBar value={searchBarValue} onChange={setGlobalFilter} />
{renderTableActions && (

View File

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

View File

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

View File

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