diff --git a/api/database/models/ingress.go b/api/database/models/ingress.go index 4e365af60..393ed0363 100644 --- a/api/database/models/ingress.go +++ b/api/database/models/ingress.go @@ -12,6 +12,7 @@ type ( Type string `json:"Type"` Availability bool `json:"Availability"` New bool `json:"New"` + Used bool `json:"Used"` } K8sIngressControllers []K8sIngressController diff --git a/api/database/models/namespaces.go b/api/database/models/namespaces.go index 587d2090d..62195e2ae 100644 --- a/api/database/models/namespaces.go +++ b/api/database/models/namespaces.go @@ -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 } diff --git a/api/datastore/migrator/migrate_dbversion70.go b/api/datastore/migrator/migrate_dbversion70.go index 884498cd9..25f50f88f 100644 --- a/api/datastore/migrator/migrate_dbversion70.go +++ b/api/datastore/migrator/migrate_dbversion70.go @@ -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 +} diff --git a/api/datastore/test_data/output_24_to_latest.json b/api/datastore/test_data/output_24_to_latest.json index fa041676d..36fb6497a 100644 --- a/api/datastore/test_data/output_24_to_latest.json +++ b/api/datastore/test_data/output_24_to_latest.json @@ -53,6 +53,7 @@ "Kubernetes": { "Configuration": { "EnableResourceOverCommit": false, + "IngressAvailabilityPerNamespace": true, "IngressClasses": null, "ResourceOverCommitPercentage": 0, "RestrictDefaultNamespace": false, diff --git a/api/http/handler/kubernetes/kubernetes_config.go b/api/http/handler/kubernetes/config.go similarity index 100% rename from api/http/handler/kubernetes/kubernetes_config.go rename to api/http/handler/kubernetes/config.go diff --git a/api/http/handler/kubernetes/configmaps_and_secrets.go b/api/http/handler/kubernetes/configmaps_and_secrets.go index c2f077e6b..b38b16640 100644 --- a/api/http/handler/kubernetes/configmaps_and_secrets.go +++ b/api/http/handler/kubernetes/configmaps_and_secrets.go @@ -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) diff --git a/api/http/handler/kubernetes/handler.go b/api/http/handler/kubernetes/handler.go index 0e6e26f44..e5846ca4e 100644 --- a/api/http/handler/kubernetes/handler.go +++ b/api/http/handler/kubernetes/handler.go @@ -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. diff --git a/api/http/handler/kubernetes/ingresses.go b/api/http/handler/kubernetes/ingresses.go index 0a6059204..40051cb4f 100644 --- a/api/http/handler/kubernetes/ingresses.go +++ b/api/http/handler/kubernetes/ingresses.go @@ -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) } diff --git a/api/http/handler/kubernetes/namespaces.go b/api/http/handler/kubernetes/namespaces.go index 01c339255..7dfc09d5a 100644 --- a/api/http/handler/kubernetes/namespaces.go +++ b/api/http/handler/kubernetes/namespaces.go @@ -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{ diff --git a/api/http/handler/kubernetes/kubernetes_nodes_limits.go b/api/http/handler/kubernetes/nodes_limits.go similarity index 100% rename from api/http/handler/kubernetes/kubernetes_nodes_limits.go rename to api/http/handler/kubernetes/nodes_limits.go diff --git a/api/http/handler/kubernetes/services.go b/api/http/handler/kubernetes/services.go index 72b3e6cd5..2e60513ce 100644 --- a/api/http/handler/kubernetes/services.go +++ b/api/http/handler/kubernetes/services.go @@ -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 } diff --git a/api/http/handler/kubernetes/namespaces_toggle_system.go b/api/http/handler/kubernetes/toggle_system.go similarity index 100% rename from api/http/handler/kubernetes/namespaces_toggle_system.go rename to api/http/handler/kubernetes/toggle_system.go diff --git a/api/kubernetes/cli/ingress.go b/api/kubernetes/cli/ingress.go index 1c105d9b0..ce7ffc442 100644 --- a/api/kubernetes/cli/ingress.go +++ b/api/kubernetes/cli/ingress.go @@ -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" diff --git a/api/kubernetes/cli/namespace.go b/api/kubernetes/cli/namespace.go index 55df12a62..18701822d 100644 --- a/api/kubernetes/cli/namespace.go +++ b/api/kubernetes/cli/namespace.go @@ -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 diff --git a/api/portainer.go b/api/portainer.go index f292332c3..4ef996126 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -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 diff --git a/app/kubernetes/react/components/index.ts b/app/kubernetes/react/components/index.ts index c8c77847c..82fbe2ac8 100644 --- a/app/kubernetes/react/components/index.ts +++ b/app/kubernetes/react/components/index.ts @@ -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, [ diff --git a/app/kubernetes/react/views/networks/ingresses/CreateIngressView/CreateIngressView.tsx b/app/kubernetes/react/views/networks/ingresses/CreateIngressView/CreateIngressView.tsx index 617dd541f..7748e58da 100644 --- a/app/kubernetes/react/views/networks/ingresses/CreateIngressView/CreateIngressView.tsx +++ b/app/kubernetes/react/views/networks/ingresses/CreateIngressView/CreateIngressView.tsx @@ -158,10 +158,12 @@ export function CreateIngressView() { ); const ingressClassOptions: Option[] = [ { 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) { diff --git a/app/kubernetes/react/views/networks/ingresses/queries.ts b/app/kubernetes/react/views/networks/ingresses/queries.ts index 44079e4ac..8b789c65e 100644 --- a/app/kubernetes/react/views/networks/ingresses/queries.ts +++ b/app/kubernetes/react/views/networks/ingresses/queries.ts @@ -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, diff --git a/app/kubernetes/views/configure/configure.html b/app/kubernetes/views/configure/configure.html index 7d3abf977..fd5c203ee 100644 --- a/app/kubernetes/views/configure/configure.html +++ b/app/kubernetes/views/configure/configure.html @@ -35,124 +35,35 @@
-
-
-
-

Configuring ingress controllers will allow users to expose application they deploy over a HTTP route.

-

- - 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. -

-
-
+ +
- - - configure ingress controller - -
- -
-
-
- Ingress class - -
-
- Type - -
-
- - -
-
- -
-
-
-
-

Ingress class name is required.

-

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').

-
-

- This ingress class is already defined. -

-
-
-
-
-
-

Ingress class type is required.

-
-
-
-
+
-
- -

- - Traefik support is experimental. -

-
-
-
Change Window Settings
@@ -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'" diff --git a/app/kubernetes/views/configure/configureController.js b/app/kubernetes/views/configure/configureController.js index 3058ecb4b..947c71281 100644 --- a/app/kubernetes/views/configure/configureController.js +++ b/app/kubernetes/views/configure/configureController.js @@ -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; diff --git a/app/kubernetes/views/resource-pools/create/createResourcePool.html b/app/kubernetes/views/resource-pools/create/createResourcePool.html index 69b6c304d..56a88b40a 100644 --- a/app/kubernetes/views/resource-pools/create/createResourcePool.html +++ b/app/kubernetes/views/resource-pools/create/createResourcePool.html @@ -180,214 +180,17 @@ - -
Storage
- -
- - - 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. - -
-
- - standard -
- - - - - -
-
Ingresses
+
-
-
- The ingress feature must be enabled in the - environment configuration view to be able to register ingresses inside this namespace. -
-
- -
-
-

- - Enable and configure ingresses available to users when deploying applications. -

-
-
- -
-
-
- {{ ic.IngressClass.Name }} -
-
-
- -
-
- -
-
- -
-
-
- -
-
-
- - - add hostname - -
-
-
-
-
- Hostname - -
-
- -
-
-
- -

Hostname is required.

-

- - This field must consist of lower case alphanumeric characters, '-' or '.', and must start and end with an alphanumeric character (e.g. 'example.com'). -

-
-

- This hostname is already used. -

-
-
-
-
-
-
-
-

- - You can specify a list of annotations that will be associated to the ingress. -

-
- -
- - - - -
-
-
-
- Key - -
-
- Value - -
-
- -
-
-
-
+
Networking
+
@@ -422,6 +225,25 @@
+ +
Storage
+ +
+ + + 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. + +
+
+ + standard +
+ + + + + diff --git a/app/kubernetes/views/resource-pools/create/createResourcePoolController.js b/app/kubernetes/views/resource-pools/create/createResourcePoolController.js index 02d125f5a..319c66e74 100644 --- a/app/kubernetes/views/resource-pools/create/createResourcePoolController.js +++ b/app/kubernetes/views/resource-pools/create/createResourcePoolController.js @@ -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; diff --git a/app/kubernetes/views/resource-pools/edit/resourcePool.html b/app/kubernetes/views/resource-pools/edit/resourcePool.html index f7b3704e5..1dc1364ff 100644 --- a/app/kubernetes/views/resource-pools/edit/resourcePool.html +++ b/app/kubernetes/views/resource-pools/edit/resourcePool.html @@ -156,193 +156,20 @@ -
-
Ingresses
+
-
-
- The ingress feature must be enabled in the - environment configuration view to be able to register ingresses inside this namespace. -
-
+
Networking
+ -
-
-

- - Enable and configure ingresses available to users when deploying applications. -

-
-
- -
-
- - {{ ic.IngressClass.Name }} -
-
- -
-
-
- -
-
- -
-
-
-
- -
-
-
- - - add hostname - -
-
-
-
-
- Hostname - -
-
- - -
-
-
- -

- - Hostname is required. -

-

- - This field must consist of lower case alphanumeric characters, '-' or '.', and must start and end with an alphanumeric character (e.g. - 'example.com'). -

-
-

- - This hostname is already used. -

-
-
-
-
-
-
-
-

- - You can specify a list of annotations that will be associated to the ingress. -

-
- -
- - - add annotation - - - - - add rewrite annotation - - - - - add regular expression annotation - - - -
- -
-
-
- Key - -
-
- Value - -
-
- -
-
-
-
-
Registries
diff --git a/app/kubernetes/views/resource-pools/edit/resourcePoolController.js b/app/kubernetes/views/resource-pools/edit/resourcePoolController.js index 1aa1480cf..444c688fb 100644 --- a/app/kubernetes/views/resource-pools/edit/resourcePoolController.js +++ b/app/kubernetes/views/resource-pools/edit/resourcePoolController.js @@ -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; diff --git a/app/portainer/react/components/index.ts b/app/portainer/react/components/index.ts index e641a4388..e2f2e2410 100644 --- a/app/portainer/react/components/index.ts +++ b/app/portainer/react/components/index.ts @@ -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( diff --git a/app/portainer/views/auth/auth.html b/app/portainer/views/auth/auth.html index a012c669e..5150b25e2 100644 --- a/app/portainer/views/auth/auth.html +++ b/app/portainer/views/auth/auth.html @@ -99,7 +99,7 @@
-
+
+ + +
+ ); + } + + function renderIngressClassDescription() { + return ( +
+
{description}
+ {ingressControllers && + ingControllerFormValues && + isUnsavedChanges(ingressControllers, ingControllerFormValues) && ( + + + Unsaved changes. + + )} +
+ ); + } + + 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) => `
  • ${controller.ClassName}
  • ` + ); + const usedControllerHtmlList = `
      ${usedControllerHtmlListItems.join( + '' + )}
    `; + confirmWarn({ + title: 'Disallow in-use ingress controllers?', + message: ` +
    +

    There are ingress controllers you want to disallow that are in use:

    + ${usedControllerHtmlList} +

    No new ingress rules can be created for the disallowed controllers.

    +
    `, + 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; +} diff --git a/app/react/kubernetes/cluster/ingressClass/IngressClassDatatable/RowContext.tsx b/app/react/kubernetes/cluster/ingressClass/IngressClassDatatable/RowContext.tsx new file mode 100644 index 000000000..c75f3e3e8 --- /dev/null +++ b/app/react/kubernetes/cluster/ingressClass/IngressClassDatatable/RowContext.tsx @@ -0,0 +1,11 @@ +import { Environment } from '@/portainer/environments/types'; + +import { createRowContext } from '@@/datatables/RowContext'; + +interface RowContextState { + environment: Environment; +} + +const { RowProvider, useRowContext } = createRowContext(); + +export { RowProvider, useRowContext }; diff --git a/app/react/kubernetes/cluster/ingressClass/IngressClassDatatable/columns/availability.tsx b/app/react/kubernetes/cluster/ingressClass/IngressClassDatatable/columns/availability.tsx new file mode 100644 index 000000000..e1c663de1 --- /dev/null +++ b/app/react/kubernetes/cluster/ingressClass/IngressClassDatatable/columns/availability.tsx @@ -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 = { + Header: 'Availability', + accessor: 'Availability', + Cell: AvailailityCell, + id: 'availability', + disableFilters: true, + canHide: true, + sortInverted: true, + sortType: 'basic', + Filter: () => null, +}; + +function AvailailityCell({ value }: CellProps) { + return ( + + + {value ? 'Allowed' : 'Disallowed'} + + ); +} diff --git a/app/react/kubernetes/cluster/ingressClass/IngressClassDatatable/columns/index.ts b/app/react/kubernetes/cluster/ingressClass/IngressClassDatatable/columns/index.ts new file mode 100644 index 000000000..66600f49f --- /dev/null +++ b/app/react/kubernetes/cluster/ingressClass/IngressClassDatatable/columns/index.ts @@ -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], []); +} diff --git a/app/react/kubernetes/cluster/ingressClass/IngressClassDatatable/columns/name.tsx b/app/react/kubernetes/cluster/ingressClass/IngressClassDatatable/columns/name.tsx new file mode 100644 index 000000000..73fd82793 --- /dev/null +++ b/app/react/kubernetes/cluster/ingressClass/IngressClassDatatable/columns/name.tsx @@ -0,0 +1,25 @@ +import { CellProps, Column } from 'react-table'; + +import { Badge } from '@@/Badge'; + +import type { IngressControllerClassMap } from '../../types'; + +export const name: Column = { + Header: 'Ingress class', + accessor: 'ClassName', + Cell: NameCell, + id: 'name', + disableFilters: true, + canHide: true, + Filter: () => null, + sortType: 'string', +}; + +function NameCell({ row }: CellProps) { + return ( + + {row.original.ClassName} + {row.original.New && Newly detected} + + ); +} diff --git a/app/react/kubernetes/cluster/ingressClass/IngressClassDatatable/columns/type.tsx b/app/react/kubernetes/cluster/ingressClass/IngressClassDatatable/columns/type.tsx new file mode 100644 index 000000000..ddd1351bd --- /dev/null +++ b/app/react/kubernetes/cluster/ingressClass/IngressClassDatatable/columns/type.tsx @@ -0,0 +1,14 @@ +import { CellProps, Column } from 'react-table'; + +import type { IngressControllerClassMap } from '../../types'; + +export const type: Column = { + Header: 'Ingress controller type', + accessor: 'Type', + Cell: ({ row }: CellProps) => + row.original.Type || '-', + id: 'type', + disableFilters: true, + canHide: true, + Filter: () => null, +}; diff --git a/app/react/kubernetes/cluster/ingressClass/IngressClassDatatable/datatable-store.ts b/app/react/kubernetes/cluster/ingressClass/IngressClassDatatable/datatable-store.ts new file mode 100644 index 000000000..ef4064d1a --- /dev/null +++ b/app/react/kubernetes/cluster/ingressClass/IngressClassDatatable/datatable-store.ts @@ -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()( + persist( + (set) => ({ + ...sortableSettings(set), + ...paginationSettings(set), + }), + { + name: keyBuilder(storageKey), + } + ) + ); +} diff --git a/app/react/kubernetes/cluster/ingressClass/IngressClassDatatable/index.ts b/app/react/kubernetes/cluster/ingressClass/IngressClassDatatable/index.ts new file mode 100644 index 000000000..0f508c5e3 --- /dev/null +++ b/app/react/kubernetes/cluster/ingressClass/IngressClassDatatable/index.ts @@ -0,0 +1 @@ +export * from './IngressClassDatatable'; diff --git a/app/react/kubernetes/cluster/ingressClass/IngressClassDatatable/types.ts b/app/react/kubernetes/cluster/ingressClass/IngressClassDatatable/types.ts new file mode 100644 index 000000000..7f64de9f2 --- /dev/null +++ b/app/react/kubernetes/cluster/ingressClass/IngressClassDatatable/types.ts @@ -0,0 +1,8 @@ +import { + PaginationTableSettings, + SortableTableSettings, +} from '@/react/components/datatables/types'; + +export interface TableSettings + extends SortableTableSettings, + PaginationTableSettings {} diff --git a/app/react/kubernetes/cluster/ingressClass/ingressClass.service.ts b/app/react/kubernetes/cluster/ingressClass/ingressClass.service.ts new file mode 100644 index 000000000..50ebeb606 --- /dev/null +++ b/app/react/kubernetes/cluster/ingressClass/ingressClass.service.ts @@ -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>( + 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`; +} diff --git a/app/react/kubernetes/cluster/ingressClass/types.ts b/app/react/kubernetes/cluster/ingressClass/types.ts new file mode 100644 index 000000000..c085bc95f --- /dev/null +++ b/app/react/kubernetes/cluster/ingressClass/types.ts @@ -0,0 +1,10 @@ +type SupportedIngControllerNames = 'nginx' | 'traefik' | 'unknown'; + +export interface IngressControllerClassMap extends Record { + Name: string; + ClassName: string; + Type: SupportedIngControllerNames; + Availability: boolean; + New: boolean; + Used: boolean; // if the controller is used by any ingress in the cluster +} diff --git a/app/react/kubernetes/cluster/ingressClass/utils/index.ts b/app/react/kubernetes/cluster/ingressClass/utils/index.ts new file mode 100644 index 000000000..8b462a37c --- /dev/null +++ b/app/react/kubernetes/cluster/ingressClass/utils/index.ts @@ -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; +} diff --git a/app/react/kubernetes/services/kubernetes/types/index.ts b/app/react/kubernetes/services/kubernetes/types/index.ts new file mode 100644 index 000000000..f6ffc0a15 --- /dev/null +++ b/app/react/kubernetes/services/kubernetes/types/index.ts @@ -0,0 +1,11 @@ +export * from './v1IngressClass'; +export * from './v1ObjectMeta'; + +export type KubernetesApiListResponse = { + apiVersion: string; + kind: string; + items: T; + metadata: { + resourceVersion?: string; + }; +}; diff --git a/app/react/kubernetes/services/kubernetes/types/v1IngressClass.ts b/app/react/kubernetes/services/kubernetes/types/v1IngressClass.ts new file mode 100644 index 000000000..4f5362de3 --- /dev/null +++ b/app/react/kubernetes/services/kubernetes/types/v1IngressClass.ts @@ -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; +}; diff --git a/app/react/kubernetes/services/kubernetes/types/v1ObjectMeta.ts b/app/react/kubernetes/services/kubernetes/types/v1ObjectMeta.ts new file mode 100644 index 000000000..7edcc20bb --- /dev/null +++ b/app/react/kubernetes/services/kubernetes/types/v1ObjectMeta.ts @@ -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; +};