From ef1d648c07b34d2572cccaafa5bdc5347676d167 Mon Sep 17 00:00:00 2001 From: Prabhat Khera <91852476+prabhat-org@users.noreply.github.com> Date: Wed, 21 Sep 2022 16:49:42 +1200 Subject: [PATCH] feat(ingress): ingresses datatable with add/edit ingresses EE-2615 (#7672) --- api/database/models/configmaps_and_secrets.go | 17 + api/database/models/ingress.go | 77 ++ api/database/models/namespaces.go | 12 + api/database/models/services.go | 64 ++ .../test_data/output_24_to_latest.json | 2 + .../kubernetes/configmaps_and_secrets.go | 33 + api/http/handler/kubernetes/handler.go | 79 ++- api/http/handler/kubernetes/ingresses.go | 410 +++++++++++ api/http/handler/kubernetes/namespaces.go | 97 +++ api/http/handler/kubernetes/services.go | 121 ++++ api/http/server.go | 2 +- api/kubernetes/cli/configmaps_and_secrets.go | 64 ++ api/kubernetes/cli/ingress.go | 243 +++++++ api/kubernetes/cli/namespace.go | 65 ++ api/kubernetes/cli/service.go | 153 ++++ api/portainer.go | 40 +- app/assets/css/button.css | 4 + app/kubernetes/__module.js | 34 + app/kubernetes/converters/configuration.js | 1 + app/kubernetes/converters/secret.js | 2 + app/kubernetes/ingress/converter.js | 1 + app/kubernetes/ingress/models.js | 1 + app/kubernetes/models/configuration/models.js | 1 + app/kubernetes/models/secret/models.js | 1 + app/kubernetes/models/service/models.js | 1 + app/kubernetes/react/views/index.ts | 12 +- .../CreateIngressView/CreateIngressView.tsx | 659 ++++++++++++++++++ .../CreateIngressView/IngressForm.tsx | 589 ++++++++++++++++ .../ingresses/CreateIngressView/index.tsx | 1 + .../ingresses/CreateIngressView/types.ts | 33 + .../ingresses/CreateIngressView/utils.ts | 132 ++++ .../IngressDatatable/IngressDataTable.tsx | 126 ++++ .../IngressDatatable/columns/className.tsx | 11 + .../IngressDatatable/columns/index.tsx | 11 + .../IngressDatatable/columns/ingressRules.tsx | 48 ++ .../IngressDatatable/columns/name.tsx | 26 + .../IngressDatatable/columns/namespace.tsx | 33 + .../IngressDatatable/columns/type.tsx | 11 + .../IngressDatatable/datatable-store.ts | 26 + .../ingresses/IngressDatatable/index.tsx | 20 + .../components/annotations/index.tsx | 84 +++ .../ingresses/components/annotations/types.ts | 5 + .../react/views/networks/ingresses/queries.ts | 160 +++++ .../react/views/networks/ingresses/service.ts | 100 +++ .../react/views/networks/ingresses/style.css | 30 + .../react/views/networks/ingresses/types.ts | 46 ++ .../react/views/networks/services/queries.ts | 27 + .../react/views/networks/services/service.ts | 23 + .../react/views/networks/services/types.ts | 33 + app/portainer/environments/types.ts | 16 + app/react-tools/test-mocks.ts | 12 +- app/react/components/Link.tsx | 11 +- app/react/components/datatables/Datatable.tsx | 6 +- app/react/components/datatables/Filter.tsx | 55 +- .../components/form-components/FormError.tsx | 19 +- .../ContainersDatatable.tsx | 2 +- app/react/kubernetes/configs/queries.ts | 29 + app/react/kubernetes/configs/service.ts | 18 + app/react/kubernetes/configs/types.ts | 17 + app/react/kubernetes/namespaces/queries.ts | 30 + app/react/kubernetes/namespaces/service.ts | 39 ++ app/react/kubernetes/namespaces/types.ts | 6 + app/react/kubernetes/services/readme.md | 5 + .../KubernetesSidebar/KubernetesSidebar.tsx | 13 +- configmaps_and_secrets.go | 50 ++ ingresses.go | 550 +++++++++++++++ namespaces.go | 162 +++++ services.go | 188 +++++ 68 files changed, 4938 insertions(+), 61 deletions(-) create mode 100644 api/database/models/configmaps_and_secrets.go create mode 100644 api/database/models/ingress.go create mode 100644 api/database/models/namespaces.go create mode 100644 api/database/models/services.go create mode 100644 api/http/handler/kubernetes/configmaps_and_secrets.go create mode 100644 api/http/handler/kubernetes/ingresses.go create mode 100644 api/http/handler/kubernetes/namespaces.go create mode 100644 api/http/handler/kubernetes/services.go create mode 100644 api/kubernetes/cli/configmaps_and_secrets.go create mode 100644 api/kubernetes/cli/ingress.go create mode 100644 api/kubernetes/cli/service.go create mode 100644 app/kubernetes/react/views/networks/ingresses/CreateIngressView/CreateIngressView.tsx create mode 100644 app/kubernetes/react/views/networks/ingresses/CreateIngressView/IngressForm.tsx create mode 100644 app/kubernetes/react/views/networks/ingresses/CreateIngressView/index.tsx create mode 100644 app/kubernetes/react/views/networks/ingresses/CreateIngressView/types.ts create mode 100644 app/kubernetes/react/views/networks/ingresses/CreateIngressView/utils.ts create mode 100644 app/kubernetes/react/views/networks/ingresses/IngressDatatable/IngressDataTable.tsx create mode 100644 app/kubernetes/react/views/networks/ingresses/IngressDatatable/columns/className.tsx create mode 100644 app/kubernetes/react/views/networks/ingresses/IngressDatatable/columns/index.tsx create mode 100644 app/kubernetes/react/views/networks/ingresses/IngressDatatable/columns/ingressRules.tsx create mode 100644 app/kubernetes/react/views/networks/ingresses/IngressDatatable/columns/name.tsx create mode 100644 app/kubernetes/react/views/networks/ingresses/IngressDatatable/columns/namespace.tsx create mode 100644 app/kubernetes/react/views/networks/ingresses/IngressDatatable/columns/type.tsx create mode 100644 app/kubernetes/react/views/networks/ingresses/IngressDatatable/datatable-store.ts create mode 100644 app/kubernetes/react/views/networks/ingresses/IngressDatatable/index.tsx create mode 100644 app/kubernetes/react/views/networks/ingresses/components/annotations/index.tsx create mode 100644 app/kubernetes/react/views/networks/ingresses/components/annotations/types.ts create mode 100644 app/kubernetes/react/views/networks/ingresses/queries.ts create mode 100644 app/kubernetes/react/views/networks/ingresses/service.ts create mode 100644 app/kubernetes/react/views/networks/ingresses/style.css create mode 100644 app/kubernetes/react/views/networks/ingresses/types.ts create mode 100644 app/kubernetes/react/views/networks/services/queries.ts create mode 100644 app/kubernetes/react/views/networks/services/service.ts create mode 100644 app/kubernetes/react/views/networks/services/types.ts create mode 100644 app/react/kubernetes/configs/queries.ts create mode 100644 app/react/kubernetes/configs/service.ts create mode 100644 app/react/kubernetes/configs/types.ts create mode 100644 app/react/kubernetes/namespaces/queries.ts create mode 100644 app/react/kubernetes/namespaces/service.ts create mode 100644 app/react/kubernetes/namespaces/types.ts create mode 100644 app/react/kubernetes/services/readme.md create mode 100644 configmaps_and_secrets.go create mode 100644 ingresses.go create mode 100644 namespaces.go create mode 100644 services.go diff --git a/api/database/models/configmaps_and_secrets.go b/api/database/models/configmaps_and_secrets.go new file mode 100644 index 000000000..3b1c827ea --- /dev/null +++ b/api/database/models/configmaps_and_secrets.go @@ -0,0 +1,17 @@ +package models + +type ( + K8sConfigMapOrSecret struct { + UID string `json:"UID"` + Name string `json:"Name"` + Namespace string `json:"Namespace"` + CreationDate string `json:"CreationDate"` + Annotations map[string]string `json:"Annotations"` + Data map[string]string `json:"Data"` + Applications []string `json:"Applications"` + IsSecret bool `json:"IsSecret"` + + // SecretType will be an empty string for config maps. + SecretType string `json:"SecretType"` + } +) diff --git a/api/database/models/ingress.go b/api/database/models/ingress.go new file mode 100644 index 000000000..4e365af60 --- /dev/null +++ b/api/database/models/ingress.go @@ -0,0 +1,77 @@ +package models + +import ( + "errors" + "net/http" +) + +type ( + K8sIngressController struct { + Name string `json:"Name"` + ClassName string `json:"ClassName"` + Type string `json:"Type"` + Availability bool `json:"Availability"` + New bool `json:"New"` + } + + K8sIngressControllers []K8sIngressController + + K8sIngressInfo struct { + Name string `json:"Name"` + UID string `json:"UID"` + Type string `json:"Type"` + Namespace string `json:"Namespace"` + ClassName string `json:"ClassName"` + Annotations map[string]string `json:"Annotations"` + Hosts []string `json:"Hosts"` + Paths []K8sIngressPath `json:"Paths"` + TLS []K8sIngressTLS `json:"TLS"` + } + + K8sIngressTLS struct { + Hosts []string `json:"Hosts"` + SecretName string `json:"SecretName"` + } + + K8sIngressPath struct { + IngressName string `json:"IngressName"` + Host string `json:"Host"` + ServiceName string `json:"ServiceName"` + Port int `json:"Port"` + Path string `json:"Path"` + PathType string `json:"PathType"` + } + + // K8sIngressDeleteRequests is a mapping of namespace names to a slice of + // ingress names. + K8sIngressDeleteRequests map[string][]string +) + +func (r K8sIngressControllers) Validate(request *http.Request) error { + return nil +} + +func (r K8sIngressInfo) Validate(request *http.Request) error { + if r.Name == "" { + return errors.New("missing ingress name from the request payload") + } + if r.Namespace == "" { + return errors.New("missing ingress Namespace from the request payload") + } + if r.ClassName == "" { + return errors.New("missing ingress ClassName from the request payload") + } + return nil +} + +func (r K8sIngressDeleteRequests) Validate(request *http.Request) error { + if len(r) == 0 { + return errors.New("missing deletion request list in payload") + } + for ns := range r { + if len(ns) == 0 { + return errors.New("deletion given with empty namespace") + } + } + return nil +} diff --git a/api/database/models/namespaces.go b/api/database/models/namespaces.go new file mode 100644 index 000000000..587d2090d --- /dev/null +++ b/api/database/models/namespaces.go @@ -0,0 +1,12 @@ +package models + +import "net/http" + +type K8sNamespaceInfo struct { + Name string `json:"Name"` + Annotations map[string]string `json:"Annotations"` +} + +func (r *K8sNamespaceInfo) Validate(request *http.Request) error { + return nil +} diff --git a/api/database/models/services.go b/api/database/models/services.go new file mode 100644 index 000000000..72d12cc4e --- /dev/null +++ b/api/database/models/services.go @@ -0,0 +1,64 @@ +package models + +import ( + "errors" + "net/http" +) + +type ( + K8sServiceInfo struct { + Name string `json:"Name"` + UID string `json:"UID"` + Type string `json:"Type"` + Namespace string `json:"Namespace"` + Annotations map[string]string `json:"Annotations"` + CreationTimestamp string `json:"CreationTimestamp"` + Labels map[string]string `json:"Labels"` + AllocateLoadBalancerNodePorts *bool `json:"AllocateLoadBalancerNodePorts,omitempty"` + Ports []K8sServicePort `json:"Ports"` + Selector map[string]string `json:"Selector"` + IngressStatus []K8sServiceIngress `json:"IngressStatus"` + } + + K8sServicePort struct { + Name string `json:"Name"` + NodePort int `json:"NodePort"` + Port int `json:"Port"` + Protocol string `json:"Protocol"` + TargetPort int `json:"TargetPort"` + } + + K8sServiceIngress struct { + IP string `json:"IP"` + Host string `json:"Host"` + } + + // K8sServiceDeleteRequests is a mapping of namespace names to a slice of + // service names. + K8sServiceDeleteRequests map[string][]string +) + +func (s *K8sServiceInfo) Validate(request *http.Request) error { + if s.Name == "" { + return errors.New("missing service name from the request payload") + } + if s.Namespace == "" { + return errors.New("missing service namespace from the request payload") + } + if s.Ports == nil { + return errors.New("missing service ports from the request payload") + } + return nil +} + +func (r K8sServiceDeleteRequests) Validate(request *http.Request) error { + if len(r) == 0 { + return errors.New("missing deletion request list in payload") + } + for ns := range r { + if len(ns) == 0 { + return errors.New("deletion given with empty namespace") + } + } + 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 0519c8a65..f491e5d8a 100644 --- a/api/datastore/test_data/output_24_to_latest.json +++ b/api/datastore/test_data/output_24_to_latest.json @@ -52,7 +52,9 @@ "IsEdgeDevice": false, "Kubernetes": { "Configuration": { + "EnableResourceOverCommit": false, "IngressClasses": null, + "ResourceOverCommitPercentage": 0, "RestrictDefaultNamespace": false, "StorageClasses": null, "UseLoadBalancer": false, diff --git a/api/http/handler/kubernetes/configmaps_and_secrets.go b/api/http/handler/kubernetes/configmaps_and_secrets.go new file mode 100644 index 000000000..c2f077e6b --- /dev/null +++ b/api/http/handler/kubernetes/configmaps_and_secrets.go @@ -0,0 +1,33 @@ +package kubernetes + +import ( + "net/http" + + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" + "github.com/portainer/libhttp/response" +) + +func (handler *Handler) getKubernetesConfigMaps(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + cli := handler.KubernetesClient + + namespace, err := request.RetrieveRouteVariableValue(r, "namespace") + if err != nil { + return &httperror.HandlerError{ + StatusCode: http.StatusBadRequest, + Message: "Invalid namespace identifier route variable", + Err: err, + } + } + + configmaps, err := cli.GetConfigMapsAndSecrets(namespace) + if err != nil { + return &httperror.HandlerError{ + StatusCode: http.StatusInternalServerError, + Message: "Unable to retrieve nodes limits", + Err: err, + } + } + + return response.JSON(w, configmaps) +} diff --git a/api/http/handler/kubernetes/handler.go b/api/http/handler/kubernetes/handler.go index 2e44f7357..0e6e26f44 100644 --- a/api/http/handler/kubernetes/handler.go +++ b/api/http/handler/kubernetes/handler.go @@ -2,11 +2,15 @@ package kubernetes import ( "errors" - "github.com/portainer/portainer/api/kubernetes" "net/http" + portainer "github.com/portainer/portainer/api" + portainerDsErrors "github.com/portainer/portainer/api/dataservices/errors" + "github.com/portainer/portainer/api/kubernetes" + "github.com/gorilla/mux" httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" "github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/http/middlewares" "github.com/portainer/portainer/api/http/security" @@ -23,10 +27,11 @@ type Handler struct { jwtService dataservices.JWTService kubernetesClientFactory *cli.ClientFactory kubeClusterAccessService kubernetes.KubeClusterAccessService + KubernetesClient portainer.KubeClient } // NewHandler creates a handler to process pre-proxied requests to external APIs. -func NewHandler(bouncer *security.RequestBouncer, authorizationService *authorization.Service, dataStore dataservices.DataStore, jwtService dataservices.JWTService, kubeClusterAccessService kubernetes.KubeClusterAccessService, kubernetesClientFactory *cli.ClientFactory) *Handler { +func NewHandler(bouncer *security.RequestBouncer, authorizationService *authorization.Service, dataStore dataservices.DataStore, jwtService dataservices.JWTService, kubeClusterAccessService kubernetes.KubeClusterAccessService, kubernetesClientFactory *cli.ClientFactory, kubernetesClient portainer.KubeClient) *Handler { h := &Handler{ Router: mux.NewRouter(), authorizationService: authorizationService, @@ -34,6 +39,7 @@ func NewHandler(bouncer *security.RequestBouncer, authorizationService *authoriz jwtService: jwtService, kubeClusterAccessService: kubeClusterAccessService, kubernetesClientFactory: kubernetesClientFactory, + KubernetesClient: kubernetesClient, } kubeRouter := h.PathPrefix("/kubernetes").Subrouter() @@ -45,15 +51,32 @@ func NewHandler(bouncer *security.RequestBouncer, authorizationService *authoriz endpointRouter := kubeRouter.PathPrefix("/{id}").Subrouter() endpointRouter.Use(middlewares.WithEndpoint(dataStore.Endpoint(), "id")) endpointRouter.Use(kubeOnlyMiddleware) + endpointRouter.Use(h.kubeClient) - endpointRouter.PathPrefix("/nodes_limits").Handler( - bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.getKubernetesNodesLimits))).Methods(http.MethodGet) + endpointRouter.PathPrefix("/nodes_limits").Handler(httperror.LoggerHandler(h.getKubernetesNodesLimits)).Methods(http.MethodGet) + endpointRouter.Handle("/ingresscontrollers", httperror.LoggerHandler(h.getKubernetesIngressControllers)).Methods(http.MethodGet) + endpointRouter.Handle("/ingresscontrollers", httperror.LoggerHandler(h.updateKubernetesIngressControllers)).Methods(http.MethodPut) + endpointRouter.Handle("/ingresses/delete", httperror.LoggerHandler(h.deleteKubernetesIngresses)).Methods(http.MethodPost) + endpointRouter.Handle("/services/delete", httperror.LoggerHandler(h.deleteKubernetesServices)).Methods(http.MethodPost) + endpointRouter.Path("/namespaces").Handler(httperror.LoggerHandler(h.createKubernetesNamespace)).Methods(http.MethodPost) + endpointRouter.Path("/namespaces").Handler(httperror.LoggerHandler(h.updateKubernetesNamespace)).Methods(http.MethodPut) + endpointRouter.Path("/namespaces").Handler(httperror.LoggerHandler(h.getKubernetesNamespaces)).Methods(http.MethodGet) + endpointRouter.Path("/namespace/{namespace}").Handler(httperror.LoggerHandler(h.deleteKubernetesNamespaces)).Methods(http.MethodDelete) // namespaces // in the future this piece of code might be in another package (or a few different packages - namespaces/namespace?) // to keep it simple, we've decided to leave it like this. namespaceRouter := endpointRouter.PathPrefix("/namespaces/{namespace}").Subrouter() namespaceRouter.Handle("/system", bouncer.RestrictedAccess(httperror.LoggerHandler(h.namespacesToggleSystem))).Methods(http.MethodPut) + namespaceRouter.Handle("/ingresscontrollers", httperror.LoggerHandler(h.getKubernetesIngressControllersByNamespace)).Methods(http.MethodGet) + namespaceRouter.Handle("/ingresscontrollers", httperror.LoggerHandler(h.updateKubernetesIngressControllersByNamespace)).Methods(http.MethodPut) + namespaceRouter.Handle("/configmaps", httperror.LoggerHandler(h.getKubernetesConfigMaps)).Methods(http.MethodGet) + namespaceRouter.Handle("/ingresses", httperror.LoggerHandler(h.createKubernetesIngress)).Methods(http.MethodPost) + namespaceRouter.Handle("/ingresses", httperror.LoggerHandler(h.updateKubernetesIngress)).Methods(http.MethodPut) + namespaceRouter.Handle("/ingresses", httperror.LoggerHandler(h.getKubernetesIngresses)).Methods(http.MethodGet) + namespaceRouter.Handle("/services", httperror.LoggerHandler(h.createKubernetesService)).Methods(http.MethodPost) + namespaceRouter.Handle("/services", httperror.LoggerHandler(h.updateKubernetesService)).Methods(http.MethodPut) + namespaceRouter.Handle("/services", httperror.LoggerHandler(h.getKubernetesServices)).Methods(http.MethodGet) return h } @@ -75,3 +98,51 @@ func kubeOnlyMiddleware(next http.Handler) http.Handler { next.ServeHTTP(rw, request) }) } + +func (handler *Handler) kubeClient(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + httperror.WriteError( + w, + http.StatusBadRequest, + "Invalid environment identifier route variable", + err, + ) + } + + endpoint, err := handler.dataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID)) + if err == portainerDsErrors.ErrObjectNotFound { + httperror.WriteError( + w, + http.StatusNotFound, + "Unable to find an environment with the specified identifier inside the database", + err, + ) + } else if err != nil { + httperror.WriteError( + w, + http.StatusInternalServerError, + "Unable to find an environment with the specified identifier inside the database", + err, + ) + } + + if handler.kubernetesClientFactory == nil { + next.ServeHTTP(w, r) + return + } + kubeCli, err := handler.kubernetesClientFactory.GetKubeClient(endpoint) + if err != nil { + httperror.WriteError( + w, + http.StatusInternalServerError, + "Unable to create Kubernetes client", + err, + ) + + } + handler.KubernetesClient = kubeCli + next.ServeHTTP(w, r) + }) +} diff --git a/api/http/handler/kubernetes/ingresses.go b/api/http/handler/kubernetes/ingresses.go new file mode 100644 index 000000000..0a6059204 --- /dev/null +++ b/api/http/handler/kubernetes/ingresses.go @@ -0,0 +1,410 @@ +package kubernetes + +import ( + "fmt" + "net/http" + + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" + "github.com/portainer/libhttp/response" + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/database/models" + portainerDsErrors "github.com/portainer/portainer/api/dataservices/errors" +) + +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, + } + } + + 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, + } + } 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, + } + } + + 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() + existingClasses := endpoint.Kubernetes.Configuration.IngressClasses + for i := range controllers { + controllers[i].Availability = true + controllers[i].New = true + + // Check if the controller is blocked globally. + for _, a := range existingClasses { + controllers[i].New = false + if controllers[i].ClassName != a.Name { + continue + } + controllers[i].New = false + + // Skip over non-global blocks. + if len(a.BlockedNamespaces) > 0 { + continue + } + + if controllers[i].ClassName == a.Name { + controllers[i].Availability = !a.Blocked + } + } + // TODO: Update existingClasses to take care of New and remove no longer + // existing classes. + } + return response.JSON(w, controllers) +} + +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, + } + } + + 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, + } + } 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, + } + } + + namespace, err := request.RetrieveRouteVariableValue(r, "namespace") + if err != nil { + return &httperror.HandlerError{ + StatusCode: http.StatusBadRequest, + Message: "Invalid namespace identifier route variable", + Err: 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() + existingClasses := endpoint.Kubernetes.Configuration.IngressClasses + for i := range controllers { + controllers[i].Availability = true + controllers[i].New = true + + // Check if the controller is blocked globally or in the current + // namespace. + for _, a := range existingClasses { + if controllers[i].ClassName != a.Name { + continue + } + controllers[i].New = false + + // If it's not blocked we're all done! + if !a.Blocked { + continue + } + + // Global blocks. + if len(a.BlockedNamespaces) == 0 { + controllers[i].Availability = false + continue + } + + // Also check the current namespace. + for _, ns := range a.BlockedNamespaces { + if namespace == ns { + controllers[i].Availability = false + } + } + } + // TODO: Update existingClasses to take care of New and remove no longer + // existing classes. + } + return response.JSON(w, controllers) +} + +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, + } + } + + 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, + } + } 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, + } + } + + var payload models.K8sIngressControllers + err = request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{ + StatusCode: http.StatusBadRequest, + Message: "Invalid request payload", + Err: 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 + } + } + } + endpoint.Kubernetes.Configuration.IngressClasses = classes + fmt.Printf("%#v\n", endpoint.Kubernetes.Configuration.IngressClasses) + 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 nil +} + +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, + } + } + + 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, + } + } 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, + } + } + + namespace, err := request.RetrieveRouteVariableValue(r, "namespace") + if err != nil { + return &httperror.HandlerError{ + StatusCode: http.StatusBadRequest, + Message: "Invalid namespace identifier route variable", + Err: 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, + } + } + + classes := endpoint.Kubernetes.Configuration.IngressClasses +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 + } + + // 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 + } + } + classes[i].BlockedNamespaces = append( + classes[i].BlockedNamespaces, + namespace, + ) + } + } + } + endpoint.Kubernetes.Configuration.IngressClasses = classes + fmt.Printf("%#v\n", endpoint.Kubernetes.Configuration.IngressClasses) + 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 nil +} + +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, + } + } + + 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 response.JSON(w, ingresses) +} + +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, + } + } + + var payload models.K8sIngressInfo + err = request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{ + StatusCode: http.StatusBadRequest, + Message: "Invalid request payload", + Err: 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 nil +} + +func (handler *Handler) deleteKubernetesIngresses(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + cli := handler.KubernetesClient + + var payload models.K8sIngressDeleteRequests + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return httperror.BadRequest("Invalid request payload", err) + } + + err = cli.DeleteIngresses(payload) + if err != nil { + return &httperror.HandlerError{ + StatusCode: http.StatusInternalServerError, + Message: "Unable to retrieve nodes limits", + Err: err, + } + } + return nil +} + +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, + } + } + + var payload models.K8sIngressInfo + err = request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{ + StatusCode: http.StatusBadRequest, + Message: "Invalid request payload", + Err: 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 nil +} diff --git a/api/http/handler/kubernetes/namespaces.go b/api/http/handler/kubernetes/namespaces.go new file mode 100644 index 000000000..01c339255 --- /dev/null +++ b/api/http/handler/kubernetes/namespaces.go @@ -0,0 +1,97 @@ +package kubernetes + +import ( + "net/http" + + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" + "github.com/portainer/libhttp/response" + "github.com/portainer/portainer/api/database/models" +) + +func (handler *Handler) getKubernetesNamespaces(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + cli := handler.KubernetesClient + + namespaces, err := cli.GetNamespaces() + if err != nil { + return &httperror.HandlerError{ + StatusCode: http.StatusInternalServerError, + Message: "Unable to retrieve nodes limits", + Err: err, + } + } + + return response.JSON(w, namespaces) +} + +func (handler *Handler) createKubernetesNamespace(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + cli := handler.KubernetesClient + + var payload models.K8sNamespaceInfo + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{ + StatusCode: http.StatusBadRequest, + Message: "Invalid request payload", + Err: err, + } + } + + err = cli.CreateNamespace(payload) + if err != nil { + return &httperror.HandlerError{ + StatusCode: http.StatusInternalServerError, + Message: "Unable to retrieve nodes limits", + Err: err, + } + } + return nil +} + +func (handler *Handler) deleteKubernetesNamespaces(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + cli := handler.KubernetesClient + + namespace, err := request.RetrieveRouteVariableValue(r, "namespace") + if err != nil { + return &httperror.HandlerError{ + StatusCode: http.StatusBadRequest, + Message: "Invalid namespace identifier route variable", + Err: err, + } + } + + err = cli.DeleteNamespace(namespace) + if err != nil { + return &httperror.HandlerError{ + StatusCode: http.StatusInternalServerError, + Message: "Unable to retrieve nodes limits", + Err: err, + } + } + + return nil +} + +func (handler *Handler) updateKubernetesNamespace(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + cli := handler.KubernetesClient + + var payload models.K8sNamespaceInfo + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{ + StatusCode: http.StatusBadRequest, + Message: "Invalid request payload", + Err: err, + } + } + + err = cli.UpdateNamespace(payload) + if err != nil { + return &httperror.HandlerError{ + StatusCode: http.StatusInternalServerError, + Message: "Unable to retrieve nodes limits", + Err: err, + } + } + return nil +} diff --git a/api/http/handler/kubernetes/services.go b/api/http/handler/kubernetes/services.go new file mode 100644 index 000000000..72b3e6cd5 --- /dev/null +++ b/api/http/handler/kubernetes/services.go @@ -0,0 +1,121 @@ +package kubernetes + +import ( + "net/http" + + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" + "github.com/portainer/libhttp/response" + "github.com/portainer/portainer/api/database/models" +) + +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, + } + } + + 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 response.JSON(w, services) +} + +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, + } + } + + var payload models.K8sServiceInfo + err = request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{ + StatusCode: http.StatusBadRequest, + Message: "Invalid request payload", + Err: 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 nil +} + +func (handler *Handler) deleteKubernetesServices(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + cli := handler.KubernetesClient + + var payload models.K8sServiceDeleteRequests + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{ + StatusCode: http.StatusBadRequest, + Message: "Invalid request payload", + Err: err, + } + } + + err = cli.DeleteServices(payload) + if err != nil { + return &httperror.HandlerError{ + StatusCode: http.StatusInternalServerError, + Message: "Unable to retrieve nodes limits", + Err: err, + } + } + return nil +} + +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, + } + } + + var payload models.K8sServiceInfo + err = request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{ + StatusCode: http.StatusBadRequest, + Message: "Invalid request payload", + Err: 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 nil +} diff --git a/api/http/server.go b/api/http/server.go index 1a603e584..111d3fae8 100644 --- a/api/http/server.go +++ b/api/http/server.go @@ -186,7 +186,7 @@ func (server *Server) Start() error { endpointProxyHandler.ProxyManager = server.ProxyManager endpointProxyHandler.ReverseTunnelService = server.ReverseTunnelService - var kubernetesHandler = kubehandler.NewHandler(requestBouncer, server.AuthorizationService, server.DataStore, server.JWTService, server.KubeClusterAccessService, server.KubernetesClientFactory) + var kubernetesHandler = kubehandler.NewHandler(requestBouncer, server.AuthorizationService, server.DataStore, server.JWTService, server.KubeClusterAccessService, server.KubernetesClientFactory, nil) var dockerHandler = dockerhandler.NewHandler(requestBouncer, server.AuthorizationService, server.DataStore, server.DockerClientFactory) diff --git a/api/kubernetes/cli/configmaps_and_secrets.go b/api/kubernetes/cli/configmaps_and_secrets.go new file mode 100644 index 000000000..e460c96c2 --- /dev/null +++ b/api/kubernetes/cli/configmaps_and_secrets.go @@ -0,0 +1,64 @@ +package cli + +import ( + "context" + "time" + + "github.com/portainer/portainer/api/database/models" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// GetConfigMapsAndSecrets gets all the ConfigMaps AND all the Secrets for a +// given namespace in a k8s endpoint. The result is a list of both config maps +// and secrets. The IsSecret boolean property indicates if a given struct is a +// secret or configmap. +func (kcl *KubeClient) GetConfigMapsAndSecrets(namespace string) ([]models.K8sConfigMapOrSecret, error) { + mapsClient := kcl.cli.CoreV1().ConfigMaps(namespace) + mapsList, err := mapsClient.List(context.Background(), v1.ListOptions{}) + if err != nil { + return nil, err + } + + // TODO: Applications + var combined []models.K8sConfigMapOrSecret + for _, m := range mapsList.Items { + var cm models.K8sConfigMapOrSecret + cm.UID = string(m.UID) + cm.Name = m.Name + cm.Namespace = m.Namespace + cm.Annotations = m.Annotations + cm.Data = m.Data + cm.CreationDate = m.CreationTimestamp.Time.UTC().Format(time.RFC3339) + cm.IsSecret = false + combined = append(combined, cm) + } + + secretClient := kcl.cli.CoreV1().Secrets(namespace) + secretList, err := secretClient.List(context.Background(), v1.ListOptions{}) + if err != nil { + return nil, err + } + + for _, s := range secretList.Items { + var secret models.K8sConfigMapOrSecret + secret.UID = string(s.UID) + secret.Name = s.Name + secret.Namespace = s.Namespace + secret.Annotations = s.Annotations + secret.Data = msbToMss(s.Data) + secret.CreationDate = s.CreationTimestamp.Time.UTC().Format(time.RFC3339) + secret.IsSecret = true + secret.SecretType = string(s.Type) + combined = append(combined, secret) + } + + return combined, nil +} + +func msbToMss(msa map[string][]byte) map[string]string { + mss := make(map[string]string, len(msa)) + for k, v := range msa { + mss[k] = string(v) + } + return mss +} diff --git a/api/kubernetes/cli/ingress.go b/api/kubernetes/cli/ingress.go new file mode 100644 index 000000000..1c105d9b0 --- /dev/null +++ b/api/kubernetes/cli/ingress.go @@ -0,0 +1,243 @@ +package cli + +import ( + "context" + "strings" + + "github.com/portainer/portainer/api/database/models" + netv1 "k8s.io/api/networking/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func (kcl *KubeClient) GetIngressControllers() models.K8sIngressControllers { + var controllers []models.K8sIngressController + + // We know that each existing class points to a controller so we can start + // by collecting these easy ones. + classClient := kcl.cli.NetworkingV1().IngressClasses() + classList, err := classClient.List(context.Background(), metav1.ListOptions{}) + if err != nil { + return nil + } + + for _, class := range classList.Items { + var controller models.K8sIngressController + controller.Name = class.Spec.Controller + controller.ClassName = class.Name + switch { + case strings.Contains(controller.Name, "nginx"): + controller.Type = "nginx" + case strings.Contains(controller.Name, "traefik"): + controller.Type = "traefik" + default: + controller.Type = "other" + } + controllers = append(controllers, controller) + } + return controllers +} + +// GetIngresses gets all the ingresses for a given namespace in a k8s endpoint. +func (kcl *KubeClient) GetIngresses(namespace string) ([]models.K8sIngressInfo, error) { + // Fetch ingress classes to build a map. We will later use the map to lookup + // each ingresses "type". + classes := make(map[string]string) + classClient := kcl.cli.NetworkingV1().IngressClasses() + classList, err := classClient.List(context.Background(), metav1.ListOptions{}) + if err != nil { + return nil, err + } + + for _, class := range classList.Items { + // Write the ingress classes "type" to our map. + classes[class.Name] = class.Spec.Controller + } + + // Fetch each ingress. + ingressClient := kcl.cli.NetworkingV1().Ingresses(namespace) + ingressList, err := ingressClient.List(context.Background(), metav1.ListOptions{}) + if err != nil { + return nil, err + } + + var infos []models.K8sIngressInfo + for _, ingress := range ingressList.Items { + ingressClass := ingress.Spec.IngressClassName + var info models.K8sIngressInfo + info.Name = ingress.Name + info.UID = string(ingress.UID) + info.Namespace = namespace + info.ClassName = "" + if ingressClass != nil { + info.ClassName = *ingressClass + } + info.Type = classes[info.ClassName] + info.Annotations = ingress.Annotations + + // Gather TLS information. + for _, v := range ingress.Spec.TLS { + var tls models.K8sIngressTLS + tls.Hosts = v.Hosts + tls.SecretName = v.SecretName + info.TLS = append(info.TLS, tls) + } + + // Gather list of paths and hosts. + hosts := make(map[string]struct{}) + for _, r := range ingress.Spec.Rules { + if r.HTTP == nil { + continue + } + + // There are multiple paths per rule. We want to flatten the list + // for our frontend. + for _, p := range r.HTTP.Paths { + var path models.K8sIngressPath + path.IngressName = info.Name + path.Host = r.Host + + // We collect all exiting hosts in a map to avoid duplicates. + // Then, later convert it to a slice for the frontend. + hosts[r.Host] = struct{}{} + + path.Path = p.Path + path.PathType = string(*p.PathType) + path.ServiceName = p.Backend.Service.Name + path.Port = int(p.Backend.Service.Port.Number) + info.Paths = append(info.Paths, path) + } + } + + // Store list of hosts. + for host := range hosts { + info.Hosts = append(info.Hosts, host) + } + + infos = append(infos, info) + } + + return infos, nil +} + +// CreateIngress creates a new ingress in a given namespace in a k8s endpoint. +func (kcl *KubeClient) CreateIngress(namespace string, info models.K8sIngressInfo) error { + ingressClient := kcl.cli.NetworkingV1().Ingresses(namespace) + var ingress netv1.Ingress + + ingress.Name = info.Name + ingress.Namespace = info.Namespace + ingress.Spec.IngressClassName = &info.ClassName + ingress.Annotations = info.Annotations + + // Store TLS information. + var tls []netv1.IngressTLS + for _, i := range info.TLS { + tls = append(tls, netv1.IngressTLS{ + Hosts: i.Hosts, + SecretName: i.SecretName, + }) + } + ingress.Spec.TLS = tls + + // Parse "paths" into rules with paths. + rules := make(map[string][]netv1.HTTPIngressPath) + for _, path := range info.Paths { + pathType := netv1.PathType(path.PathType) + rules[path.Host] = append(rules[path.Host], netv1.HTTPIngressPath{ + Path: path.Path, + PathType: &pathType, + Backend: netv1.IngressBackend{ + Service: &netv1.IngressServiceBackend{ + Name: path.ServiceName, + Port: netv1.ServiceBackendPort{ + Number: int32(path.Port), + }, + }, + }, + }) + } + + for rule, paths := range rules { + ingress.Spec.Rules = append(ingress.Spec.Rules, netv1.IngressRule{ + Host: rule, + IngressRuleValue: netv1.IngressRuleValue{ + HTTP: &netv1.HTTPIngressRuleValue{ + Paths: paths, + }, + }, + }) + } + + _, err := ingressClient.Create(context.Background(), &ingress, metav1.CreateOptions{}) + return err +} + +// DeleteIngresses processes a K8sIngressDeleteRequest by deleting each ingress +// in its given namespace. +func (kcl *KubeClient) DeleteIngresses(reqs models.K8sIngressDeleteRequests) error { + var err error + for namespace := range reqs { + for _, ingress := range reqs[namespace] { + ingressClient := kcl.cli.NetworkingV1().Ingresses(namespace) + err = ingressClient.Delete( + context.Background(), + ingress, + metav1.DeleteOptions{}, + ) + } + } + return err +} + +// UpdateIngress updates an existing ingress in a given namespace in a k8s endpoint. +func (kcl *KubeClient) UpdateIngress(namespace string, info models.K8sIngressInfo) error { + ingressClient := kcl.cli.NetworkingV1().Ingresses(namespace) + var ingress netv1.Ingress + + ingress.Name = info.Name + ingress.Namespace = info.Namespace + ingress.Spec.IngressClassName = &info.ClassName + ingress.Annotations = info.Annotations + + // Store TLS information. + var tls []netv1.IngressTLS + for _, i := range info.TLS { + tls = append(tls, netv1.IngressTLS{ + Hosts: i.Hosts, + SecretName: i.SecretName, + }) + } + ingress.Spec.TLS = tls + + // Parse "paths" into rules with paths. + rules := make(map[string][]netv1.HTTPIngressPath) + for _, path := range info.Paths { + pathType := netv1.PathType(path.PathType) + rules[path.Host] = append(rules[path.Host], netv1.HTTPIngressPath{ + Path: path.Path, + PathType: &pathType, + Backend: netv1.IngressBackend{ + Service: &netv1.IngressServiceBackend{ + Name: path.ServiceName, + Port: netv1.ServiceBackendPort{ + Number: int32(path.Port), + }, + }, + }, + }) + } + + for rule, paths := range rules { + ingress.Spec.Rules = append(ingress.Spec.Rules, netv1.IngressRule{ + Host: rule, + IngressRuleValue: netv1.IngressRuleValue{ + HTTP: &netv1.HTTPIngressRuleValue{ + Paths: paths, + }, + }, + }) + } + + _, err := ingressClient.Update(context.Background(), &ingress, metav1.UpdateOptions{}) + return err +} diff --git a/api/kubernetes/cli/namespace.go b/api/kubernetes/cli/namespace.go index 66fe57ec1..55df12a62 100644 --- a/api/kubernetes/cli/namespace.go +++ b/api/kubernetes/cli/namespace.go @@ -2,9 +2,12 @@ package cli import ( "context" + "fmt" "strconv" "github.com/pkg/errors" + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/database/models" v1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) @@ -22,6 +25,37 @@ func defaultSystemNamespaces() map[string]struct{} { } } +// GetNamespaces gets the namespaces in the current k8s environment(endpoint). +func (kcl *KubeClient) GetNamespaces() (map[string]portainer.K8sNamespaceInfo, error) { + namespaces, err := kcl.cli.CoreV1().Namespaces().List(context.TODO(), metav1.ListOptions{}) + if err != nil { + return nil, err + } + + results := make(map[string]portainer.K8sNamespaceInfo) + + for _, ns := range namespaces.Items { + results[ns.Name] = portainer.K8sNamespaceInfo{ + IsSystem: isSystemNamespace(ns), + IsDefault: ns.Name == defaultNamespace, + } + } + + return results, nil +} + +// CreateIngress creates a new ingress in a given namespace in a k8s endpoint. +func (kcl *KubeClient) CreateNamespace(info models.K8sNamespaceInfo) error { + client := kcl.cli.CoreV1().Namespaces() + + var ns v1.Namespace + ns.Name = info.Name + ns.Annotations = info.Annotations + + _, err := client.Create(context.Background(), &ns, metav1.CreateOptions{}) + return err +} + func isSystemNamespace(namespace v1.Namespace) bool { systemLabelValue, hasSystemLabel := namespace.Labels[systemNamespaceLabel] if hasSystemLabel { @@ -72,3 +106,34 @@ func (kcl *KubeClient) ToggleSystemState(namespaceName string, isSystem bool) er return nil } + +// UpdateIngress updates an ingress in a given namespace in a k8s endpoint. +func (kcl *KubeClient) UpdateNamespace(info models.K8sNamespaceInfo) error { + client := kcl.cli.CoreV1().Namespaces() + + var ns v1.Namespace + ns.Name = info.Name + ns.Annotations = info.Annotations + + _, err := client.Update(context.Background(), &ns, metav1.UpdateOptions{}) + return err +} + +func (kcl *KubeClient) DeleteNamespace(namespace string) error { + client := kcl.cli.CoreV1().Namespaces() + namespaces, err := client.List(context.Background(), metav1.ListOptions{}) + if err != nil { + return err + } + + for _, ns := range namespaces.Items { + if ns.Name == namespace { + return client.Delete( + context.Background(), + namespace, + metav1.DeleteOptions{}, + ) + } + } + return fmt.Errorf("namespace %s not found", namespace) +} diff --git a/api/kubernetes/cli/service.go b/api/kubernetes/cli/service.go new file mode 100644 index 000000000..6cfb53f8b --- /dev/null +++ b/api/kubernetes/cli/service.go @@ -0,0 +1,153 @@ +package cli + +import ( + "context" + + models "github.com/portainer/portainer/api/database/models" + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/intstr" +) + +// GetServices gets all the services for a given namespace in a k8s endpoint. +func (kcl *KubeClient) GetServices(namespace string) ([]models.K8sServiceInfo, error) { + client := kcl.cli.CoreV1().Services(namespace) + + services, err := client.List(context.Background(), metav1.ListOptions{}) + if err != nil { + return nil, err + } + + var result []models.K8sServiceInfo + + for _, service := range services.Items { + servicePorts := make([]models.K8sServicePort, 0) + for _, port := range service.Spec.Ports { + servicePorts = append(servicePorts, models.K8sServicePort{ + Name: port.Name, + NodePort: int(port.NodePort), + Port: int(port.Port), + Protocol: string(port.Protocol), + TargetPort: port.TargetPort.IntValue(), + }) + } + + ingressStatus := make([]models.K8sServiceIngress, 0) + for _, status := range service.Status.LoadBalancer.Ingress { + ingressStatus = append(ingressStatus, models.K8sServiceIngress{ + IP: status.IP, + Host: status.Hostname, + }) + } + + result = append(result, models.K8sServiceInfo{ + Name: service.Name, + UID: string(service.GetUID()), + Type: string(service.Spec.Type), + Namespace: service.Namespace, + CreationTimestamp: service.GetCreationTimestamp().String(), + AllocateLoadBalancerNodePorts: service.Spec.AllocateLoadBalancerNodePorts, + Ports: servicePorts, + IngressStatus: ingressStatus, + Labels: service.GetLabels(), + Annotations: service.GetAnnotations(), + }) + } + + return result, nil +} + +// CreateService creates a new service in a given namespace in a k8s endpoint. +func (kcl *KubeClient) CreateService(namespace string, info models.K8sServiceInfo) error { + ServiceClient := kcl.cli.CoreV1().Services(namespace) + var service v1.Service + + service.Name = info.Name + service.Spec.Type = v1.ServiceType(info.Type) + service.Namespace = info.Namespace + service.Annotations = info.Annotations + service.Labels = info.Labels + service.Spec.AllocateLoadBalancerNodePorts = info.AllocateLoadBalancerNodePorts + service.Spec.Selector = info.Selector + + // Set ports. + for _, p := range info.Ports { + var port v1.ServicePort + port.Name = p.Name + port.NodePort = int32(p.NodePort) + port.Port = int32(p.Port) + port.Protocol = v1.Protocol(p.Protocol) + port.TargetPort = intstr.FromInt(p.TargetPort) + service.Spec.Ports = append(service.Spec.Ports, port) + } + + // Set ingresses. + for _, i := range info.IngressStatus { + var ing v1.LoadBalancerIngress + ing.IP = i.IP + ing.Hostname = i.Host + service.Status.LoadBalancer.Ingress = append( + service.Status.LoadBalancer.Ingress, + ing, + ) + } + + _, err := ServiceClient.Create(context.Background(), &service, metav1.CreateOptions{}) + return err +} + +// DeleteServices processes a K8sServiceDeleteRequest by deleting each service +// in its given namespace. +func (kcl *KubeClient) DeleteServices(reqs models.K8sServiceDeleteRequests) error { + var err error + for namespace := range reqs { + for _, service := range reqs[namespace] { + serviceClient := kcl.cli.CoreV1().Services(namespace) + err = serviceClient.Delete( + context.Background(), + service, + metav1.DeleteOptions{}, + ) + } + } + return err +} + +// UpdateService updates service in a given namespace in a k8s endpoint. +func (kcl *KubeClient) UpdateService(namespace string, info models.K8sServiceInfo) error { + ServiceClient := kcl.cli.CoreV1().Services(namespace) + var service v1.Service + + service.Name = info.Name + service.Spec.Type = v1.ServiceType(info.Type) + service.Namespace = info.Namespace + service.Annotations = info.Annotations + service.Labels = info.Labels + service.Spec.AllocateLoadBalancerNodePorts = info.AllocateLoadBalancerNodePorts + service.Spec.Selector = info.Selector + + // Set ports. + for _, p := range info.Ports { + var port v1.ServicePort + port.Name = p.Name + port.NodePort = int32(p.NodePort) + port.Port = int32(p.Port) + port.Protocol = v1.Protocol(p.Protocol) + port.TargetPort = intstr.FromInt(p.TargetPort) + service.Spec.Ports = append(service.Spec.Ports, port) + } + + // Set ingresses. + for _, i := range info.IngressStatus { + var ing v1.LoadBalancerIngress + ing.IP = i.IP + ing.Hostname = i.Host + service.Status.LoadBalancer.Ingress = append( + service.Status.LoadBalancer.Ingress, + ing, + ) + } + + _, err := ServiceClient.Update(context.Background(), &service, metav1.UpdateOptions{}) + return err +} diff --git a/api/portainer.go b/api/portainer.go index 4cdb2cbb5..b148e49f9 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -7,6 +7,7 @@ import ( "github.com/docker/docker/api/types" "github.com/docker/docker/api/types/volume" + "github.com/portainer/portainer/api/database/models" gittypes "github.com/portainer/portainer/api/git/types" v1 "k8s.io/api/core/v1" ) @@ -511,6 +512,11 @@ type ( // JobType represents a job type JobType int + K8sNamespaceInfo struct { + IsSystem bool `json:"IsSystem"` + IsDefault bool `json:"IsDefault"` + } + K8sNodeLimits struct { CPU int64 `json:"CPU"` Memory int64 `json:"Memory"` @@ -540,11 +546,13 @@ type ( // KubernetesConfiguration represents the configuration of a Kubernetes environment(endpoint) KubernetesConfiguration struct { - UseLoadBalancer bool `json:"UseLoadBalancer"` - UseServerMetrics bool `json:"UseServerMetrics"` - 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"` } // KubernetesStorageClassConfig represents a Kubernetes Storage Class configuration @@ -557,8 +565,10 @@ type ( // KubernetesIngressClassConfig represents a Kubernetes Ingress Class configuration KubernetesIngressClassConfig struct { - Name string `json:"Name"` - Type string `json:"Type"` + Name string `json:"Name"` + Type string `json:"Type"` + Blocked bool `json:"Blocked"` + BlockedNamespaces []string `json:"BlockedNamespaces"` } // KubernetesShellPod represents a Kubectl Shell details to facilitate pod exec functionality @@ -1330,6 +1340,22 @@ type ( GetServiceAccountBearerToken(userID int) (string, error) CreateUserShellPod(ctx context.Context, serviceAccountName, shellPodImage string) (*KubernetesShellPod, error) StartExecProcess(token string, useAdminToken bool, namespace, podName, containerName string, command []string, stdin io.Reader, stdout io.Writer, errChan chan error) + + CreateNamespace(info models.K8sNamespaceInfo) error + UpdateNamespace(info models.K8sNamespaceInfo) error + GetNamespaces() (map[string]K8sNamespaceInfo, error) + DeleteNamespace(namespace string) error + GetConfigMapsAndSecrets(namespace string) ([]models.K8sConfigMapOrSecret, error) + CreateIngress(namespace string, info models.K8sIngressInfo) error + UpdateIngress(namespace string, info models.K8sIngressInfo) error + GetIngresses(namespace string) ([]models.K8sIngressInfo, error) + DeleteIngresses(reqs models.K8sIngressDeleteRequests) error + CreateService(namespace string, service models.K8sServiceInfo) error + 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) diff --git a/app/assets/css/button.css b/app/assets/css/button.css index c7dca8fae..13bab5f1b 100644 --- a/app/assets/css/button.css +++ b/app/assets/css/button.css @@ -159,3 +159,7 @@ a.hyperlink { @apply text-blue-8 hover:text-blue-9; @apply hover:underline cursor-pointer; } + +a.no-decoration { + text-decoration: none; +} diff --git a/app/kubernetes/__module.js b/app/kubernetes/__module.js index 9ef8d1ee7..4f5d0f5b3 100644 --- a/app/kubernetes/__module.js +++ b/app/kubernetes/__module.js @@ -67,6 +67,36 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo }, }; + const ingresses = { + name: 'kubernetes.ingresses', + url: '/ingresses', + views: { + 'content@': { + component: 'kubernetesIngressesView', + }, + }, + }; + + const ingressesCreate = { + name: 'kubernetes.ingresses.create', + url: '/add', + views: { + 'content@': { + component: 'kubernetesIngressesCreateView', + }, + }, + }; + + const ingressesEdit = { + name: 'kubernetes.ingresses.edit', + url: '/:namespace/:name/edit', + views: { + 'content@': { + component: 'kubernetesIngressesCreateView', + }, + }, + }; + const applications = { name: 'kubernetes.applications', url: '/applications', @@ -376,5 +406,9 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo $stateRegistryProvider.register(registriesAccess); $stateRegistryProvider.register(endpointKubernetesConfiguration); $stateRegistryProvider.register(endpointKubernetesSecurityConstraint); + + $stateRegistryProvider.register(ingresses); + $stateRegistryProvider.register(ingressesCreate); + $stateRegistryProvider.register(ingressesEdit); }, ]); diff --git a/app/kubernetes/converters/configuration.js b/app/kubernetes/converters/configuration.js index c5e4c32fb..cf3eb11cc 100644 --- a/app/kubernetes/converters/configuration.js +++ b/app/kubernetes/converters/configuration.js @@ -15,6 +15,7 @@ class KubernetesConfigurationConverter { }); res.ConfigurationOwner = secret.ConfigurationOwner; res.IsRegistrySecret = secret.IsRegistrySecret; + res.SecretType = secret.SecretType; return res; } diff --git a/app/kubernetes/converters/secret.js b/app/kubernetes/converters/secret.js index 8f824e631..79c038f61 100644 --- a/app/kubernetes/converters/secret.js +++ b/app/kubernetes/converters/secret.js @@ -61,6 +61,8 @@ class KubernetesSecretConverter { res.Yaml = yaml ? yaml.data : ''; + res.SecretType = payload.type; + res.Data = _.map(payload.data, (value, key) => { const annotations = payload.metadata.annotations ? payload.metadata.annotations[KubernetesPortainerConfigurationDataAnnotation] : ''; const entry = new KubernetesConfigurationFormValuesEntry(); diff --git a/app/kubernetes/ingress/converter.js b/app/kubernetes/ingress/converter.js index 4fea053da..774d2690a 100644 --- a/app/kubernetes/ingress/converter.js +++ b/app/kubernetes/ingress/converter.js @@ -43,6 +43,7 @@ export class KubernetesIngressConverter { if (idx >= 0) { res.Hosts.splice(idx, 1, ''); } + res.TLS = data.spec.tls; return res; } diff --git a/app/kubernetes/ingress/models.js b/app/kubernetes/ingress/models.js index 4c8aadd2c..3b140dfc5 100644 --- a/app/kubernetes/ingress/models.js +++ b/app/kubernetes/ingress/models.js @@ -8,6 +8,7 @@ export function KubernetesIngress() { // PreviousHost: undefined, // only use for RP ingress host edit Paths: [], IngressClassName: '', + TLS: [], }; } diff --git a/app/kubernetes/models/configuration/models.js b/app/kubernetes/models/configuration/models.js index e34b07f25..8d8bc8615 100644 --- a/app/kubernetes/models/configuration/models.js +++ b/app/kubernetes/models/configuration/models.js @@ -14,6 +14,7 @@ const _KubernetesConfiguration = Object.freeze({ Used: false, Applications: [], Data: {}, + SecretType: '', }); export class KubernetesConfiguration { diff --git a/app/kubernetes/models/secret/models.js b/app/kubernetes/models/secret/models.js index d9d4316e7..b4b60e59b 100644 --- a/app/kubernetes/models/secret/models.js +++ b/app/kubernetes/models/secret/models.js @@ -9,6 +9,7 @@ const _KubernetesApplicationSecret = Object.freeze({ ConfigurationOwner: '', Yaml: '', Data: [], + SecretType: '', }); export class KubernetesApplicationSecret { diff --git a/app/kubernetes/models/service/models.js b/app/kubernetes/models/service/models.js index 39b110642..19423eca8 100644 --- a/app/kubernetes/models/service/models.js +++ b/app/kubernetes/models/service/models.js @@ -57,6 +57,7 @@ const _KubernetesIngressServiceRoute = Object.freeze({ IngressName: '', Path: '', ServiceName: '', + TLSCert: '', }); export class KubernetesIngressServiceRoute { diff --git a/app/kubernetes/react/views/index.ts b/app/kubernetes/react/views/index.ts index 39013491b..6e4288c9a 100644 --- a/app/kubernetes/react/views/index.ts +++ b/app/kubernetes/react/views/index.ts @@ -1,6 +1,10 @@ import angular from 'angular'; -export const viewsModule = angular.module( - 'portainer.kubernetes.react.views', - [] -).name; +import { r2a } from '@/react-tools/react2angular'; +import { IngressesDatatableView } from '@/kubernetes/react/views/networks/ingresses/IngressDatatable'; +import { CreateIngressView } from '@/kubernetes/react/views/networks/ingresses/CreateIngressView'; + +export const viewsModule = angular + .module('portainer.kubernetes.react.views', []) + .component('kubernetesIngressesView', r2a(IngressesDatatableView, [])) + .component('kubernetesIngressesCreateView', r2a(CreateIngressView, [])).name; diff --git a/app/kubernetes/react/views/networks/ingresses/CreateIngressView/CreateIngressView.tsx b/app/kubernetes/react/views/networks/ingresses/CreateIngressView/CreateIngressView.tsx new file mode 100644 index 000000000..617dd541f --- /dev/null +++ b/app/kubernetes/react/views/networks/ingresses/CreateIngressView/CreateIngressView.tsx @@ -0,0 +1,659 @@ +import { useState, useEffect, useMemo, ReactNode } from 'react'; +import { useCurrentStateAndParams, useRouter } from '@uirouter/react'; +import { v4 as uuidv4 } from 'uuid'; + +import { useEnvironmentId } from '@/portainer/hooks/useEnvironmentId'; +import { useConfigurations } from '@/react/kubernetes/configs/queries'; +import { useNamespaces } from '@/react/kubernetes/namespaces/queries'; +import { Annotation } from '@/kubernetes/react/views/networks/ingresses/components/annotations/types'; +import { useServices } from '@/kubernetes/react/views/networks/services/queries'; +import { notifySuccess } from '@/portainer/services/notifications'; + +import { Link } from '@@/Link'; +import { PageHeader } from '@@/PageHeader'; +import { Option } from '@@/form-components/Input/Select'; +import { Button } from '@@/buttons'; + +import { Ingress } from '../types'; +import { + useCreateIngress, + useIngresses, + useUpdateIngress, + useIngressControllers, +} from '../queries'; + +import { Rule, Path, Host } from './types'; +import { IngressForm } from './IngressForm'; +import { + prepareTLS, + preparePaths, + prepareAnnotations, + prepareRuleFromIngress, + checkIfPathExistsWithHost, +} from './utils'; + +export function CreateIngressView() { + const environmentId = useEnvironmentId(); + const { params } = useCurrentStateAndParams(); + + const router = useRouter(); + const isEdit = !!params.namespace; + + const [namespace, setNamespace] = useState(params.namespace || ''); + const [ingressRule, setIngressRule] = useState({} as Rule); + + const [errors, setErrors] = useState>( + {} as Record + ); + + const namespacesResults = useNamespaces(environmentId); + + const servicesResults = useServices(environmentId, namespace); + const configResults = useConfigurations(environmentId, namespace); + const ingressesResults = useIngresses( + environmentId, + namespacesResults.data ? Object.keys(namespacesResults?.data || {}) : [] + ); + const ingressControllersResults = useIngressControllers( + environmentId, + namespace + ); + + const createIngressMutation = useCreateIngress(); + const updateIngressMutation = useUpdateIngress(); + + const isLoading = + (servicesResults.isLoading && + configResults.isLoading && + namespacesResults.isLoading && + ingressesResults.isLoading) || + (isEdit && !ingressRule.IngressName); + + const [ingressNames, ingresses, ruleCounterByNamespace, hostWithTLS] = + useMemo((): [ + string[], + Ingress[], + Record, + Record + ] => { + const ruleCounterByNamespace: Record = {}; + const hostWithTLS: Record = {}; + ingressesResults.data?.forEach((ingress) => { + ingress.TLS?.forEach((tls) => { + tls.Hosts.forEach((host) => { + hostWithTLS[host] = tls.SecretName; + }); + }); + }); + const ingressNames: string[] = []; + ingressesResults.data?.forEach((ing) => { + ruleCounterByNamespace[ing.Namespace] = + ruleCounterByNamespace[ing.Namespace] || 0; + const n = ing.Name.match(/^(.*)-(\d+)$/); + if (n?.length === 3) { + ruleCounterByNamespace[ing.Namespace] = Math.max( + ruleCounterByNamespace[ing.Namespace], + Number(n[2]) + ); + } + if (ing.Namespace === namespace) { + ingressNames.push(ing.Name); + } + }); + return [ + ingressNames || [], + ingressesResults.data || [], + ruleCounterByNamespace, + hostWithTLS, + ]; + }, [ingressesResults.data, namespace]); + + const namespacesOptions: Option[] = [ + { label: 'Select a namespace', value: '' }, + ]; + Object.entries(namespacesResults?.data || {}).forEach(([ns, val]) => { + if (!val.IsSystem) { + namespacesOptions.push({ + label: ns, + value: ns, + }); + } + }); + + const clusterIpServices = useMemo( + () => servicesResults.data?.filter((s) => s.Type === 'ClusterIP'), + [servicesResults.data] + ); + const servicesOptions = useMemo( + () => + clusterIpServices?.map((service) => ({ + label: service.Name, + value: service.Name, + })), + [clusterIpServices] + ); + + const serviceOptions = [ + { label: 'Select a service', value: '' }, + ...(servicesOptions || []), + ]; + const servicePorts = clusterIpServices + ? Object.fromEntries( + clusterIpServices?.map((service) => [ + service.Name, + service.Ports.map((port) => ({ + label: String(port.Port), + value: String(port.Port), + })), + ]) + ) + : {}; + + const existingIngressClass = useMemo( + () => + ingressControllersResults.data?.find( + (i) => i.ClassName === ingressRule.IngressClassName + ), + [ingressControllersResults.data, ingressRule.IngressClassName] + ); + const ingressClassOptions: Option[] = [ + { label: 'Select an ingress class', value: '' }, + ...(ingressControllersResults.data?.map((cls) => ({ + label: cls.ClassName, + value: cls.ClassName, + })) || []), + ]; + + if (!existingIngressClass && ingressRule.IngressClassName) { + ingressClassOptions.push({ + label: !ingressRule.IngressType + ? `${ingressRule.IngressClassName} - NOT FOUND` + : `${ingressRule.IngressClassName} - DISALLOWED`, + value: ingressRule.IngressClassName, + }); + } + + const matchedConfigs = configResults?.data?.filter( + (config) => + config.SecretType === 'kubernetes.io/tls' && + config.Namespace === namespace + ); + const tlsOptions: Option[] = [ + { label: 'No TLS', value: '' }, + ...(matchedConfigs?.map((config) => ({ + label: config.Name, + value: config.Name, + })) || []), + ]; + + useEffect(() => { + if (!!params.name && ingressesResults.data && !ingressRule.IngressName) { + // if it is an edit screen, prepare the rule from the ingress + const ing = ingressesResults.data?.find( + (ing) => ing.Name === params.name && ing.Namespace === params.namespace + ); + if (ing) { + const type = ingressControllersResults.data?.find( + (c) => c.ClassName === ing.ClassName + )?.Type; + const r = prepareRuleFromIngress(ing); + r.IngressType = type; + setIngressRule(r); + } + } + }, [ + params.name, + ingressesResults.data, + ingressControllersResults.data, + ingressRule.IngressName, + params.namespace, + ]); + + useEffect(() => { + if (namespace.length > 0) { + validate( + ingressRule, + ingressNames || [], + servicesOptions || [], + !!existingIngressClass + ); + } + }, [ + ingressRule, + namespace, + ingressNames, + servicesOptions, + existingIngressClass, + ]); + + return ( + <> + +
+
+ +
+ {namespace && !isLoading && ( +
+ +
+ )} +
+ + ); + + function validate( + ingressRule: Rule, + ingressNames: string[], + serviceOptions: Option[], + existingIngressClass: boolean + ) { + const errors: Record = {}; + const rule = { ...ingressRule }; + + // User cannot edit the namespace and the ingress name + if (!isEdit) { + if (!rule.Namespace) { + errors.namespace = 'Namespace is required'; + } + + const nameRegex = /^[a-z]([a-z0-9-]{0,61}[a-z0-9])?$/; + if (!rule.IngressName) { + errors.ingressName = 'Ingress name is required'; + } else if (!nameRegex.test(rule.IngressName)) { + errors.ingressName = + "This field must consist of lower case alphanumeric characters or '-', contain at most 63 characters, start with an alphabetic character, and end with an alphanumeric character (e.g. 'my-name', or 'abc-123')."; + } else if (ingressNames.includes(rule.IngressName)) { + errors.ingressName = 'Ingress name already exists'; + } + + if (!rule.IngressClassName) { + errors.className = 'Ingress class is required'; + } + } + + if (isEdit && !ingressRule.IngressClassName) { + errors.className = + 'No ingress class is currently set for this ingress - use of the Portainer UI requires one to be set.'; + } + + if (isEdit && !existingIngressClass && ingressRule.IngressClassName) { + if (!rule.IngressType) { + errors.className = + 'Currently set to an ingress class that cannot be found in the cluster - you must select a valid class.'; + } else { + errors.className = + 'Currently set to an ingress class that you do not have access to - you must select a valid class.'; + } + } + + const duplicatedAnnotations: string[] = []; + rule.Annotations?.forEach((a, i) => { + if (!a.Key) { + errors[`annotations.key[${i}]`] = 'Annotation key is required'; + } else if (duplicatedAnnotations.includes(a.Key)) { + errors[`annotations.key[${i}]`] = 'Annotation cannot be duplicated'; + } + if (!a.Value) { + errors[`annotations.value[${i}]`] = 'Annotation value is required'; + } + duplicatedAnnotations.push(a.Key); + }); + + const duplicatedHosts: string[] = []; + // Check if the paths are duplicates + rule.Hosts?.forEach((host, hi) => { + if (!host.NoHost) { + if (!host.Host) { + errors[`hosts[${hi}].host`] = 'Host is required'; + } else if (duplicatedHosts.includes(host.Host)) { + errors[`hosts[${hi}].host`] = 'Host cannot be duplicated'; + } + duplicatedHosts.push(host.Host); + } + + // Validate service + host.Paths?.forEach((path, pi) => { + if (!path.ServiceName) { + errors[`hosts[${hi}].paths[${pi}].servicename`] = + 'Service name is required'; + } + + if ( + isEdit && + path.ServiceName && + !serviceOptions.find((s) => s.value === path.ServiceName) + ) { + errors[`hosts[${hi}].paths[${pi}].servicename`] = ( + + Currently set to {path.ServiceName}, which does not exist. You can + create a service with this name for a particular deployment via{' '} + + Applications + + , and on returning here it will be picked up. + + ); + } + + if (!path.ServicePort) { + errors[`hosts[${hi}].paths[${pi}].serviceport`] = + 'Service port is required'; + } + }); + // Validate paths + const paths = host.Paths.map((path) => path.Route); + paths.forEach((item, idx) => { + if (!item) { + errors[`hosts[${hi}].paths[${idx}].path`] = 'Path cannot be empty'; + } else if (paths.indexOf(item) !== idx) { + errors[`hosts[${hi}].paths[${idx}].path`] = + 'Paths cannot be duplicated'; + } else { + // Validate host and path combination globally + const isExists = checkIfPathExistsWithHost( + ingresses, + host.Host, + item, + params.name + ); + if (isExists) { + errors[`hosts[${hi}].paths[${idx}].path`] = + 'Path is already in use with the same host'; + } + } + }); + }); + + setErrors(errors); + if (Object.keys(errors).length > 0) { + return false; + } + return true; + } + + function handleNamespaceChange(ns: string) { + setNamespace(ns); + if (!isEdit) { + addNewIngress(ns); + } + } + + function handleIngressChange(key: string, val: string) { + setIngressRule((prevRules) => { + const rule = { ...prevRules, [key]: val }; + if (key === 'IngressClassName') { + rule.IngressType = ingressControllersResults.data?.find( + (c) => c.ClassName === val + )?.Type; + } + return rule; + }); + } + + function handleTLSChange(hostIndex: number, tls: string) { + setIngressRule((prevRules) => { + const rule = { ...prevRules }; + rule.Hosts[hostIndex] = { ...rule.Hosts[hostIndex], Secret: tls }; + return rule; + }); + } + + function handleHostChange(hostIndex: number, val: string) { + setIngressRule((prevRules) => { + const rule = { ...prevRules }; + rule.Hosts[hostIndex] = { ...rule.Hosts[hostIndex], Host: val }; + rule.Hosts[hostIndex].Secret = + hostWithTLS[val] || rule.Hosts[hostIndex].Secret; + return rule; + }); + } + + function handlePathChange( + hostIndex: number, + pathIndex: number, + key: 'Route' | 'PathType' | 'ServiceName' | 'ServicePort', + val: string + ) { + setIngressRule((prevRules) => { + const rule = { ...prevRules }; + const h = { ...rule.Hosts[hostIndex] }; + h.Paths[pathIndex] = { + ...h.Paths[pathIndex], + [key]: key === 'ServicePort' ? Number(val) : val, + }; + + // set the first port of the service as the default port + if ( + key === 'ServiceName' && + servicePorts[val] && + servicePorts[val].length > 0 + ) { + h.Paths[pathIndex].ServicePort = Number(servicePorts[val][0].value); + } + + rule.Hosts[hostIndex] = h; + return rule; + }); + } + + function handleAnnotationChange( + index: number, + key: 'Key' | 'Value', + val: string + ) { + setIngressRule((prevRules) => { + const rules = { ...prevRules }; + + rules.Annotations = rules.Annotations || []; + rules.Annotations[index] = rules.Annotations[index] || { + Key: '', + Value: '', + }; + rules.Annotations[index][key] = val; + + return rules; + }); + } + + function addNewIngress(namespace: string) { + const newKey = `${namespace}-ingress-${ + (ruleCounterByNamespace[namespace] || 0) + 1 + }`; + const path: Path = { + Key: uuidv4(), + ServiceName: '', + ServicePort: 0, + Route: '', + PathType: 'Prefix', + }; + + const host: Host = { + Host: '', + Secret: '', + Paths: [path], + Key: uuidv4(), + }; + + const rule: Rule = { + Key: uuidv4(), + Namespace: namespace, + IngressName: newKey, + IngressClassName: '', + Hosts: [host], + }; + + setIngressRule(rule); + } + + function addNewIngressHost(noHost = false) { + const rule = { ...ingressRule }; + + const path: Path = { + ServiceName: '', + ServicePort: 0, + Route: '', + PathType: 'Prefix', + Key: uuidv4(), + }; + + const host: Host = { + Host: '', + Secret: '', + Paths: [path], + NoHost: noHost, + Key: uuidv4(), + }; + + rule.Hosts.push(host); + setIngressRule(rule); + } + + function addNewIngressRoute(hostIndex: number) { + const rule = { ...ingressRule }; + + const path: Path = { + ServiceName: '', + ServicePort: 0, + Route: '', + PathType: 'Prefix', + Key: uuidv4(), + }; + + rule.Hosts[hostIndex].Paths.push(path); + setIngressRule(rule); + } + + function addNewAnnotation(type?: 'rewrite' | 'regex') { + const rule = { ...ingressRule }; + + const annotation: Annotation = { + Key: '', + Value: '', + ID: uuidv4(), + }; + if (type === 'rewrite') { + annotation.Key = 'nginx.ingress.kubernetes.io/rewrite-target'; + annotation.Value = '/$1'; + } + if (type === 'regex') { + annotation.Key = 'nginx.ingress.kubernetes.io/use-regex'; + annotation.Value = 'true'; + } + rule.Annotations = rule.Annotations || []; + rule.Annotations?.push(annotation); + setIngressRule(rule); + } + + function removeAnnotation(index: number) { + const rule = { ...ingressRule }; + + if (index > -1) { + rule.Annotations?.splice(index, 1); + } + + setIngressRule(rule); + } + + function removeIngressRoute(hostIndex: number, pathIndex: number) { + const rule = { ...ingressRule, Hosts: [...ingressRule.Hosts] }; + if (hostIndex > -1 && pathIndex > -1) { + rule.Hosts[hostIndex].Paths.splice(pathIndex, 1); + } + setIngressRule(rule); + } + + function removeIngressHost(hostIndex: number) { + const rule = { ...ingressRule, Hosts: [...ingressRule.Hosts] }; + if (hostIndex > -1) { + rule.Hosts.splice(hostIndex, 1); + } + setIngressRule(rule); + } + + function reloadTLSCerts() { + configResults.refetch(); + } + + function handleCreateIngressRules() { + const rule = { ...ingressRule }; + + const ingress: Ingress = { + Namespace: namespace, + Name: rule.IngressName, + ClassName: rule.IngressClassName, + Hosts: rule.Hosts.map((host) => host.Host), + Paths: preparePaths(rule.IngressName, rule.Hosts), + TLS: prepareTLS(rule.Hosts), + Annotations: prepareAnnotations(rule.Annotations || []), + }; + + if (isEdit) { + updateIngressMutation.mutate( + { environmentId, ingress }, + { + onSuccess: () => { + notifySuccess('Success', 'Ingress updated successfully'); + router.stateService.go('kubernetes.ingresses'); + }, + } + ); + } else { + createIngressMutation.mutate( + { environmentId, ingress }, + { + onSuccess: () => { + notifySuccess('Success', 'Ingress created successfully'); + router.stateService.go('kubernetes.ingresses'); + }, + } + ); + } + } +} diff --git a/app/kubernetes/react/views/networks/ingresses/CreateIngressView/IngressForm.tsx b/app/kubernetes/react/views/networks/ingresses/CreateIngressView/IngressForm.tsx new file mode 100644 index 000000000..ff3661f0f --- /dev/null +++ b/app/kubernetes/react/views/networks/ingresses/CreateIngressView/IngressForm.tsx @@ -0,0 +1,589 @@ +import { ChangeEvent, ReactNode } from 'react'; +import { Plus, RefreshCw, Trash2 } from 'react-feather'; + +import { Annotations } from '@/kubernetes/react/views/networks/ingresses/components/annotations'; + +import { Link } from '@@/Link'; +import { Icon } from '@@/Icon'; +import { Select, Option } from '@@/form-components/Input/Select'; +import { FormError } from '@@/form-components/FormError'; +import { Widget, WidgetBody, WidgetTitle } from '@@/Widget'; +import { Tooltip } from '@@/Tip/Tooltip'; +import { Button } from '@@/buttons'; + +import { Rule, ServicePorts } from './types'; + +import '../style.css'; + +const PathTypes: Record = { + nginx: ['ImplementationSpecific', 'Prefix', 'Exact'], + traefik: ['Prefix', 'Exact'], + other: ['Prefix', 'Exact'], +}; +const PlaceholderAnnotations: Record = { + nginx: ['e.g. nginx.ingress.kubernetes.io/rewrite-target', '/$1'], + traefik: ['e.g. traefik.ingress.kubernetes.io/router.tls', 'true'], + other: ['e.g. app.kubernetes.io/name', 'examplename'], +}; + +interface Props { + environmentID: number; + rule: Rule; + + errors: Record; + isLoading: boolean; + isEdit: boolean; + namespace: string; + + servicePorts: ServicePorts; + ingressClassOptions: Option[]; + serviceOptions: Option[]; + tlsOptions: Option[]; + namespacesOptions: Option[]; + + removeIngressRoute: (hostIndex: number, pathIndex: number) => void; + removeIngressHost: (hostIndex: number) => void; + removeAnnotation: (index: number) => void; + + addNewIngressHost: (noHost?: boolean) => void; + addNewIngressRoute: (hostIndex: number) => void; + addNewAnnotation: (type?: 'rewrite' | 'regex') => void; + + handleNamespaceChange: (val: string) => void; + handleHostChange: (hostIndex: number, val: string) => void; + handleTLSChange: (hostIndex: number, tls: string) => void; + handleIngressChange: ( + key: 'IngressName' | 'IngressClassName', + value: string + ) => void; + handleAnnotationChange: ( + index: number, + key: 'Key' | 'Value', + val: string + ) => void; + handlePathChange: ( + hostIndex: number, + pathIndex: number, + key: 'Route' | 'PathType' | 'ServiceName' | 'ServicePort', + val: string + ) => void; + + reloadTLSCerts: () => void; +} + +export function IngressForm({ + environmentID, + rule, + isLoading, + isEdit, + servicePorts, + tlsOptions, + handleTLSChange, + addNewIngressHost, + serviceOptions, + handleHostChange, + handleIngressChange, + handlePathChange, + addNewIngressRoute, + removeIngressRoute, + removeIngressHost, + addNewAnnotation, + removeAnnotation, + reloadTLSCerts, + handleAnnotationChange, + ingressClassOptions, + errors, + namespacesOptions, + handleNamespaceChange, + namespace, +}: Props) { + if (isLoading) { + return
Loading...
; + } + const hasNoHostRule = rule.Hosts?.some((host) => host.NoHost); + const placeholderAnnotation = + PlaceholderAnnotations[rule.IngressType || 'other']; + const pathTypes = PathTypes[rule.IngressType || 'other']; + + return ( + + + +
+
+
+ +
+ {isEdit ? ( + namespace + ) : ( + ) => + handleIngressChange('IngressName', e.target.value) + } + disabled={isEdit} + /> + )} + {errors.ingressName && !isEdit && ( + + {errors.ingressName} + + )} +
+
+ +
+ +
+ ) => + handleHostChange(hostIndex, e.target.value) + } + /> +
+ {errors[`hosts[${hostIndex}].host`] && ( + + {errors[`hosts[${hostIndex}].host`]} + + )} +
+ +
+
+ TLS secret + ) => + handlePathChange( + hostIndex, + pathIndex, + 'ServiceName', + e.target.value + ) + } + defaultValue={path.ServiceName} + /> +
+ {errors[ + `hosts[${hostIndex}].paths[${pathIndex}].servicename` + ] && ( + + { + errors[ + `hosts[${hostIndex}].paths[${pathIndex}].servicename` + ] + } + + )} +
+ +
+ {servicePorts && ( + <> +
+ + Service port + + ({ + label: type, + value: type, + })) + : [] + } + onChange={(e: ChangeEvent) => + handlePathChange( + hostIndex, + pathIndex, + 'PathType', + e.target.value + ) + } + defaultValue={path.PathType} + /> +
+ {errors[ + `hosts[${hostIndex}].paths[${pathIndex}].pathType` + ] && ( + + { + errors[ + `hosts[${hostIndex}].paths[${pathIndex}].pathType` + ] + } + + )} +
+ +
+
+ Path + ) => + handlePathChange( + hostIndex, + pathIndex, + 'Route', + e.target.value + ) + } + /> +
+ {errors[ + `hosts[${hostIndex}].paths[${pathIndex}].path` + ] && ( + + { + errors[ + `hosts[${hostIndex}].paths[${pathIndex}].path` + ] + } + + )} +
+ +
+
+
+ ))} + +
+ +
+
+ + ))} + + {namespace && ( +
+
+ + + + +
+
+ )} +
+
+ ); +} diff --git a/app/kubernetes/react/views/networks/ingresses/CreateIngressView/index.tsx b/app/kubernetes/react/views/networks/ingresses/CreateIngressView/index.tsx new file mode 100644 index 000000000..2b0ad59ec --- /dev/null +++ b/app/kubernetes/react/views/networks/ingresses/CreateIngressView/index.tsx @@ -0,0 +1 @@ +export { CreateIngressView } from './CreateIngressView'; diff --git a/app/kubernetes/react/views/networks/ingresses/CreateIngressView/types.ts b/app/kubernetes/react/views/networks/ingresses/CreateIngressView/types.ts new file mode 100644 index 000000000..5329df929 --- /dev/null +++ b/app/kubernetes/react/views/networks/ingresses/CreateIngressView/types.ts @@ -0,0 +1,33 @@ +import { Annotation } from '@/kubernetes/react/views/networks/ingresses/components/annotations/types'; + +import { Option } from '@@/form-components/Input/Select'; + +export interface Path { + Key: string; + Route: string; + ServiceName: string; + ServicePort: number; + PathType?: string; +} + +export interface Host { + Key: string; + Host: string; + Secret: string; + Paths: Path[]; + NoHost?: boolean; +} + +export interface Rule { + Key: string; + IngressName: string; + Namespace: string; + IngressClassName: string; + Hosts: Host[]; + Annotations?: Annotation[]; + IngressType?: string; +} + +export interface ServicePorts { + [serviceName: string]: Option[]; +} diff --git a/app/kubernetes/react/views/networks/ingresses/CreateIngressView/utils.ts b/app/kubernetes/react/views/networks/ingresses/CreateIngressView/utils.ts new file mode 100644 index 000000000..53adeaa20 --- /dev/null +++ b/app/kubernetes/react/views/networks/ingresses/CreateIngressView/utils.ts @@ -0,0 +1,132 @@ +import { v4 as uuidv4 } from 'uuid'; + +import { Annotation } from '@/kubernetes/react/views/networks/ingresses/components/annotations/types'; + +import { TLS, Ingress } from '../types'; + +import { Host, Rule } from './types'; + +const ignoreAnnotationsForEdit = [ + 'kubectl.kubernetes.io/last-applied-configuration', +]; + +export function prepareTLS(hosts: Host[]) { + const tls: TLS[] = []; + hosts.forEach((host) => { + if (host.Secret && host.Host) { + tls.push({ + Hosts: [host.Host], + SecretName: host.Secret, + }); + } + }); + return tls; +} + +export function preparePaths(ingressName: string, hosts: Host[]) { + return hosts.flatMap((host) => + host.Paths.map((p) => ({ + ServiceName: p.ServiceName, + Host: host.Host, + Path: p.Route, + Port: p.ServicePort, + PathType: p.PathType || 'Prefix', + IngressName: ingressName, + })) + ); +} + +export function prepareAnnotations(annotations: Annotation[]) { + const result: Record = {}; + annotations.forEach((a) => { + result[a.Key] = a.Value; + }); + return result; +} + +function getSecretByHost(host: string, tls?: TLS[]) { + let secret = ''; + if (tls) { + tls.forEach((t) => { + if (t.Hosts.indexOf(host) !== -1) { + secret = t.SecretName; + } + }); + } + return secret; +} + +export function prepareRuleHostsFromIngress(ing: Ingress) { + const hosts = ing.Hosts?.map((host) => { + const h: Host = {} as Host; + h.Host = host; + h.Secret = getSecretByHost(host, ing.TLS); + h.Paths = []; + ing.Paths.forEach((path) => { + if (path.Host === host) { + h.Paths.push({ + Route: path.Path, + ServiceName: path.ServiceName, + ServicePort: path.Port, + PathType: path.PathType, + Key: Math.random().toString(), + }); + } + }); + if (!host) { + h.NoHost = true; + } + h.Key = uuidv4(); + return h; + }); + + return hosts; +} + +export function getAnnotationsForEdit( + annotations: Record +): Annotation[] { + const result: Annotation[] = []; + Object.keys(annotations).forEach((k) => { + if (ignoreAnnotationsForEdit.indexOf(k) === -1) { + result.push({ + Key: k, + Value: annotations[k], + ID: uuidv4(), + }); + } + }); + return result; +} + +export function prepareRuleFromIngress(ing: Ingress): Rule { + return { + Key: uuidv4(), + IngressName: ing.Name, + Namespace: ing.Namespace, + IngressClassName: ing.ClassName, + Hosts: prepareRuleHostsFromIngress(ing) || [], + Annotations: ing.Annotations ? getAnnotationsForEdit(ing.Annotations) : [], + IngressType: ing.Type, + }; +} + +export function checkIfPathExistsWithHost( + ingresses: Ingress[], + host: string, + path: string, + ingressName?: string +) { + let exists = false; + ingresses.forEach((ingress) => { + if (ingressName && ingress.Name === ingressName) { + return; + } + ingress.Paths?.forEach((p) => { + if (p.Host === host && p.Path === path) { + exists = true; + } + }); + }); + return exists; +} diff --git a/app/kubernetes/react/views/networks/ingresses/IngressDatatable/IngressDataTable.tsx b/app/kubernetes/react/views/networks/ingresses/IngressDatatable/IngressDataTable.tsx new file mode 100644 index 000000000..9b693e21c --- /dev/null +++ b/app/kubernetes/react/views/networks/ingresses/IngressDatatable/IngressDataTable.tsx @@ -0,0 +1,126 @@ +import { Plus, Trash2 } from 'react-feather'; +import { useRouter } from '@uirouter/react'; + +import { useEnvironmentId } from '@/portainer/hooks/useEnvironmentId'; +import { useNamespaces } from '@/react/kubernetes/namespaces/queries'; +import { Authorized } from '@/portainer/hooks/useUser'; +import { confirmDeletionAsync } from '@/portainer/services/modal.service/confirm'; + +import { Datatable } from '@@/datatables'; +import { Button } from '@@/buttons'; +import { Link } from '@@/Link'; + +import { DeleteIngressesRequest, Ingress } from '../types'; +import { useDeleteIngresses, useIngresses } from '../queries'; + +import { createStore } from './datatable-store'; +import { useColumns } from './columns'; + +import '../style.css'; + +interface SelectedIngress { + Namespace: string; + Name: string; +} + +const useStore = createStore('ingresses'); + +export function IngressDataTable() { + const environmentId = useEnvironmentId(); + + const nsResult = useNamespaces(environmentId); + const result = useIngresses(environmentId, Object.keys(nsResult?.data || {})); + + const settings = useStore(); + + const columns = useColumns(); + const deleteIngressesMutation = useDeleteIngresses(); + + const router = useRouter(); + + return ( + row.Name + row.Type + row.Namespace} + renderTableActions={tableActions} + /> + ); + + function tableActions(selectedFlatRows: Ingress[]) { + return ( +
+ + + + + + + + + + + + + + +
+ ); + } + + async function handleRemoveClick(ingresses: SelectedIngress[]) { + const confirmed = await confirmDeletionAsync( + 'Are you sure you want to delete the selected ingresses?' + ); + if (!confirmed) { + return null; + } + + const payload: DeleteIngressesRequest = {} as DeleteIngressesRequest; + ingresses.forEach((ingress) => { + payload[ingress.Namespace] = payload[ingress.Namespace] || []; + payload[ingress.Namespace].push(ingress.Name); + }); + + deleteIngressesMutation.mutate( + { environmentId, data: payload }, + { + onSuccess: () => { + router.stateService.reload(); + }, + } + ); + return ingresses; + } +} diff --git a/app/kubernetes/react/views/networks/ingresses/IngressDatatable/columns/className.tsx b/app/kubernetes/react/views/networks/ingresses/IngressDatatable/columns/className.tsx new file mode 100644 index 000000000..6d635aaa4 --- /dev/null +++ b/app/kubernetes/react/views/networks/ingresses/IngressDatatable/columns/className.tsx @@ -0,0 +1,11 @@ +import { Column } from 'react-table'; + +import { Ingress } from '../../types'; + +export const className: Column = { + Header: 'Class Name', + accessor: 'ClassName', + id: 'className', + disableFilters: true, + canHide: true, +}; diff --git a/app/kubernetes/react/views/networks/ingresses/IngressDatatable/columns/index.tsx b/app/kubernetes/react/views/networks/ingresses/IngressDatatable/columns/index.tsx new file mode 100644 index 000000000..795b1333c --- /dev/null +++ b/app/kubernetes/react/views/networks/ingresses/IngressDatatable/columns/index.tsx @@ -0,0 +1,11 @@ +import { useMemo } from 'react'; + +import { name } from './name'; +import { type } from './type'; +import { namespace } from './namespace'; +import { className } from './className'; +import { ingressRules } from './ingressRules'; + +export function useColumns() { + return useMemo(() => [name, namespace, className, type, ingressRules], []); +} diff --git a/app/kubernetes/react/views/networks/ingresses/IngressDatatable/columns/ingressRules.tsx b/app/kubernetes/react/views/networks/ingresses/IngressDatatable/columns/ingressRules.tsx new file mode 100644 index 000000000..b38436068 --- /dev/null +++ b/app/kubernetes/react/views/networks/ingresses/IngressDatatable/columns/ingressRules.tsx @@ -0,0 +1,48 @@ +import { CellProps, Column } from 'react-table'; + +import { Icon } from '@@/Icon'; + +import { Ingress, TLS, Path } from '../../types'; + +function isHTTP(TLSs: TLS[], host: string) { + return TLSs.filter((t) => t.Hosts.indexOf(host) !== -1).length === 0; +} + +function link(host: string, path: string, isHttp: boolean) { + if (!host) { + return path; + } + return ( + + {`${isHttp ? 'http' : 'https'}://${host}${path}`} + + ); +} + +export const ingressRules: Column = { + Header: 'Rules and Paths', + accessor: 'Paths', + Cell: ({ row }: CellProps) => { + const results = row.original.Paths?.map((path: Path) => { + const isHttp = isHTTP(row.original.TLS || [], path.Host); + return ( +
+ {link(path.Host, path.Path, isHttp)} + + + + {`${path.ServiceName}:${path.Port}`} +
+ ); + }); + return results ||
; + }, + id: 'ingressRules', + disableFilters: true, + canHide: true, + disableSortBy: true, +}; diff --git a/app/kubernetes/react/views/networks/ingresses/IngressDatatable/columns/name.tsx b/app/kubernetes/react/views/networks/ingresses/IngressDatatable/columns/name.tsx new file mode 100644 index 000000000..2abe9ba21 --- /dev/null +++ b/app/kubernetes/react/views/networks/ingresses/IngressDatatable/columns/name.tsx @@ -0,0 +1,26 @@ +import { CellProps, Column } from 'react-table'; + +import { Link } from '@@/Link'; + +import { Ingress } from '../../types'; + +export const name: Column = { + Header: 'Name', + accessor: 'Name', + Cell: ({ row }: CellProps) => ( + + {row.original.Name} + + ), + id: 'name', + disableFilters: true, + canHide: true, +}; diff --git a/app/kubernetes/react/views/networks/ingresses/IngressDatatable/columns/namespace.tsx b/app/kubernetes/react/views/networks/ingresses/IngressDatatable/columns/namespace.tsx new file mode 100644 index 000000000..0aee8888f --- /dev/null +++ b/app/kubernetes/react/views/networks/ingresses/IngressDatatable/columns/namespace.tsx @@ -0,0 +1,33 @@ +import { CellProps, Column, Row } from 'react-table'; + +import { filterHOC } from '@/react/components/datatables/Filter'; + +import { Link } from '@@/Link'; + +import { Ingress } from '../../types'; + +export const namespace: Column = { + Header: 'Namespace', + accessor: 'Namespace', + Cell: ({ row }: CellProps) => ( + + {row.original.Namespace} + + ), + id: 'namespace', + disableFilters: false, + canHide: true, + Filter: filterHOC('Filter by namespace'), + filter: (rows: Row[], filterValue, filters) => { + if (filters.length === 0) { + return rows; + } + return rows.filter((r) => filters.includes(r.original.Namespace)); + }, +}; diff --git a/app/kubernetes/react/views/networks/ingresses/IngressDatatable/columns/type.tsx b/app/kubernetes/react/views/networks/ingresses/IngressDatatable/columns/type.tsx new file mode 100644 index 000000000..6996f2a6c --- /dev/null +++ b/app/kubernetes/react/views/networks/ingresses/IngressDatatable/columns/type.tsx @@ -0,0 +1,11 @@ +import { Column } from 'react-table'; + +import { Ingress } from '../../types'; + +export const type: Column = { + Header: 'Type', + accessor: 'Type', + id: 'type', + disableFilters: true, + canHide: true, +}; diff --git a/app/kubernetes/react/views/networks/ingresses/IngressDatatable/datatable-store.ts b/app/kubernetes/react/views/networks/ingresses/IngressDatatable/datatable-store.ts new file mode 100644 index 000000000..5f5384eed --- /dev/null +++ b/app/kubernetes/react/views/networks/ingresses/IngressDatatable/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/kubernetes/react/views/networks/ingresses/IngressDatatable/index.tsx b/app/kubernetes/react/views/networks/ingresses/IngressDatatable/index.tsx new file mode 100644 index 000000000..555c32c30 --- /dev/null +++ b/app/kubernetes/react/views/networks/ingresses/IngressDatatable/index.tsx @@ -0,0 +1,20 @@ +import { PageHeader } from '@@/PageHeader'; + +import { IngressDataTable } from './IngressDataTable'; + +export function IngressesDatatableView() { + return ( + <> + + + + ); +} diff --git a/app/kubernetes/react/views/networks/ingresses/components/annotations/index.tsx b/app/kubernetes/react/views/networks/ingresses/components/annotations/index.tsx new file mode 100644 index 000000000..71ac3b4b7 --- /dev/null +++ b/app/kubernetes/react/views/networks/ingresses/components/annotations/index.tsx @@ -0,0 +1,84 @@ +import { ChangeEvent, ReactNode } from 'react'; + +import { Icon } from '@@/Icon'; +import { FormError } from '@@/form-components/FormError'; + +import { Annotation } from './types'; + +interface Props { + annotations: Annotation[]; + handleAnnotationChange: ( + index: number, + key: 'Key' | 'Value', + val: string + ) => void; + removeAnnotation: (index: number) => void; + errors: Record; + placeholder: string[]; +} + +export function Annotations({ + annotations, + handleAnnotationChange, + removeAnnotation, + errors, + placeholder, +}: Props) { + return ( + <> + {annotations.map((annotation, i) => ( +
+
+
+ Key + ) => + handleAnnotationChange(i, 'Key', e.target.value) + } + /> +
+ {errors[`annotations.key[${i}]`] && ( + + {errors[`annotations.key[${i}]`]} + + )} +
+
+
+ Value + ) => + handleAnnotationChange(i, 'Value', e.target.value) + } + /> +
+ {errors[`annotations.value[${i}]`] && ( + + {errors[`annotations.value[${i}]`]} + + )} +
+
+ +
+
+ ))} + + ); +} diff --git a/app/kubernetes/react/views/networks/ingresses/components/annotations/types.ts b/app/kubernetes/react/views/networks/ingresses/components/annotations/types.ts new file mode 100644 index 000000000..6464a9e31 --- /dev/null +++ b/app/kubernetes/react/views/networks/ingresses/components/annotations/types.ts @@ -0,0 +1,5 @@ +export interface Annotation { + Key: string; + Value: string; + ID: string; +} diff --git a/app/kubernetes/react/views/networks/ingresses/queries.ts b/app/kubernetes/react/views/networks/ingresses/queries.ts new file mode 100644 index 000000000..44079e4ac --- /dev/null +++ b/app/kubernetes/react/views/networks/ingresses/queries.ts @@ -0,0 +1,160 @@ +import { useQuery, useMutation, useQueryClient } from 'react-query'; + +import { EnvironmentId } from '@/portainer/environments/types'; +import { + mutationOptions, + withError, + withInvalidate, +} from '@/react-tools/react-query'; + +import { + getIngresses, + getIngress, + createIngress, + deleteIngresses, + updateIngress, + getIngressControllers, +} from './service'; +import { DeleteIngressesRequest, Ingress } from './types'; + +const ingressKeys = { + all: ['environments', 'kubernetes', 'namespace', 'ingress'] as const, + namespace: ( + environmentId: EnvironmentId, + namespace: string, + ingress: string + ) => [...ingressKeys.all, String(environmentId), namespace, ingress] as const, +}; + +export function useIngress( + environmentId: EnvironmentId, + namespace: string, + name: string +) { + return useQuery( + [ + 'environments', + environmentId, + 'kubernetes', + 'namespace', + namespace, + 'ingress', + name, + ], + async () => { + const ing = await getIngress(environmentId, namespace, name); + return ing; + }, + { + ...withError('Unable to get ingress'), + } + ); +} + +export function useIngresses( + environmentId: EnvironmentId, + namespaces: string[] +) { + return useQuery( + [ + 'environments', + environmentId, + 'kubernetes', + 'namespace', + namespaces, + '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; + }, + { + enabled: namespaces.length > 0, + ...withError('Unable to get ingresses'), + } + ); +} + +export function useCreateIngress() { + const queryClient = useQueryClient(); + return useMutation( + ({ + environmentId, + ingress, + }: { + environmentId: EnvironmentId; + ingress: Ingress; + }) => createIngress(environmentId, ingress), + mutationOptions( + withError('Unable to create ingress controller'), + withInvalidate(queryClient, [ingressKeys.all]) + ) + ); +} + +export function useUpdateIngress() { + const queryClient = useQueryClient(); + return useMutation( + ({ + environmentId, + ingress, + }: { + environmentId: EnvironmentId; + ingress: Ingress; + }) => updateIngress(environmentId, ingress), + mutationOptions( + withError('Unable to update ingress controller'), + withInvalidate(queryClient, [ingressKeys.all]) + ) + ); +} + +export function useDeleteIngresses() { + const queryClient = useQueryClient(); + return useMutation( + ({ + environmentId, + data, + }: { + environmentId: EnvironmentId; + data: DeleteIngressesRequest; + }) => deleteIngresses(environmentId, data), + mutationOptions( + withError('Unable to update ingress controller'), + withInvalidate(queryClient, [ingressKeys.all]) + ) + ); +} + +/** + * Ingress Controllers + */ +export function useIngressControllers( + environmentId: EnvironmentId, + namespace: string +) { + return useQuery( + [ + 'environments', + environmentId, + 'kubernetes', + 'namespace', + namespace, + 'ingresscontrollers', + ], + async () => { + const ing = await getIngressControllers(environmentId, namespace); + return ing; + }, + { + enabled: !!namespace, + ...withError('Unable to get ingress controllers'), + } + ); +} diff --git a/app/kubernetes/react/views/networks/ingresses/service.ts b/app/kubernetes/react/views/networks/ingresses/service.ts new file mode 100644 index 000000000..490d98614 --- /dev/null +++ b/app/kubernetes/react/views/networks/ingresses/service.ts @@ -0,0 +1,100 @@ +import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { EnvironmentId } from '@/portainer/environments/types'; + +import { Ingress, DeleteIngressesRequest, IngressController } from './types'; + +export async function getIngress( + environmentId: EnvironmentId, + namespace: string, + ingressName: string +) { + try { + const { data: ingress } = await axios.get( + buildUrl(environmentId, namespace, ingressName) + ); + return ingress[0]; + } catch (e) { + throw parseAxiosError(e as Error, 'Unable to retrieve the ingress'); + } +} + +export async function getIngresses( + environmentId: EnvironmentId, + namespace: string +) { + try { + const { data: ingresses } = await axios.get( + buildUrl(environmentId, namespace) + ); + return ingresses; + } catch (e) { + throw parseAxiosError(e as Error, 'Unable to retrieve ingresses'); + } +} + +export async function getIngressControllers( + environmentId: EnvironmentId, + namespace: string +) { + try { + const { data: ingresscontrollers } = await axios.get( + `kubernetes/${environmentId}/namespaces/${namespace}/ingresscontrollers` + ); + return ingresscontrollers; + } catch (e) { + throw parseAxiosError(e as Error, 'Unable to retrieve ingresses'); + } +} + +export async function createIngress( + environmentId: EnvironmentId, + ingress: Ingress +) { + try { + return await axios.post( + buildUrl(environmentId, ingress.Namespace), + ingress + ); + } catch (e) { + throw parseAxiosError(e as Error, 'Unable to create an ingress'); + } +} + +export async function updateIngress( + environmentId: EnvironmentId, + ingress: Ingress +) { + try { + return await axios.put(buildUrl(environmentId, ingress.Namespace), ingress); + } catch (e) { + throw parseAxiosError(e as Error, 'Unable to update an ingress'); + } +} + +export async function deleteIngresses( + environmentId: EnvironmentId, + data: DeleteIngressesRequest +) { + try { + return await axios.post( + `kubernetes/${environmentId}/ingresses/delete`, + data + ); + } catch (e) { + throw parseAxiosError(e as Error, 'Unable to delete ingresses'); + } +} + +function buildUrl( + environmentId: EnvironmentId, + namespace: string, + ingressName?: string +) { + let url = `kubernetes/${environmentId}/namespaces/${namespace}/ingresses`; + + if (ingressName) { + url += `/${ingressName}`; + } + + return url; +} diff --git a/app/kubernetes/react/views/networks/ingresses/style.css b/app/kubernetes/react/views/networks/ingresses/style.css new file mode 100644 index 000000000..928aeecb8 --- /dev/null +++ b/app/kubernetes/react/views/networks/ingresses/style.css @@ -0,0 +1,30 @@ +.ingress-rules .bordered { + border: 1px solid var(--border-color); + border-radius: 5px; +} + +.ingress-rules .rule { + background-color: var(--bg-body-color); +} + +.ingressDatatable-actions button > span, +.anntation-actions button > span, +.rules-action button > span, +.rule button > span { + display: inline-flex; + align-items: center; + gap: 5px; +} + +.error-inline { + display: block; +} + +.error-inline svg { + margin-right: 5px; +} + +.error-inline svg, +.error-inline span { + display: inline; +} diff --git a/app/kubernetes/react/views/networks/ingresses/types.ts b/app/kubernetes/react/views/networks/ingresses/types.ts new file mode 100644 index 000000000..7787eb73b --- /dev/null +++ b/app/kubernetes/react/views/networks/ingresses/types.ts @@ -0,0 +1,46 @@ +import { + PaginationTableSettings, + SortableTableSettings, +} from '@/react/components/datatables/types'; + +export interface TableSettings + extends SortableTableSettings, + PaginationTableSettings {} + +export interface Path { + IngressName: string; + ServiceName: string; + Host: string; + Port: number; + Path: string; + PathType: string; +} + +export interface TLS { + Hosts: string[]; + SecretName: string; +} + +export type Ingress = { + Name: string; + UID?: string; + Namespace: string; + ClassName: string; + Annotations?: Record; + Hosts?: string[]; + Paths: Path[]; + TLS?: TLS[]; + Type?: string; +}; + +export interface DeleteIngressesRequest { + [key: string]: string[]; +} + +export interface IngressController { + Name: string; + ClassName: string; + Availability: string; + Type: string; + New: boolean; +} diff --git a/app/kubernetes/react/views/networks/services/queries.ts b/app/kubernetes/react/views/networks/services/queries.ts new file mode 100644 index 000000000..e3cdf8e12 --- /dev/null +++ b/app/kubernetes/react/views/networks/services/queries.ts @@ -0,0 +1,27 @@ +import { useQuery } from 'react-query'; + +import { EnvironmentId } from '@/portainer/environments/types'; +import { error as notifyError } from '@/portainer/services/notifications'; + +import { getServices } from './service'; +import { Service } from './types'; + +export function useServices(environmentId: EnvironmentId, namespace: string) { + return useQuery( + [ + 'environments', + environmentId, + 'kubernetes', + 'namespaces', + namespace, + 'services', + ], + () => + namespace ? getServices(environmentId, namespace) : ([] as Service[]), + { + onError: (err) => { + notifyError('Failure', err as Error, 'Unable to get services'); + }, + } + ); +} diff --git a/app/kubernetes/react/views/networks/services/service.ts b/app/kubernetes/react/views/networks/services/service.ts new file mode 100644 index 000000000..7b6b2c6e0 --- /dev/null +++ b/app/kubernetes/react/views/networks/services/service.ts @@ -0,0 +1,23 @@ +import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { EnvironmentId } from '@/portainer/environments/types'; + +import { Service } from './types'; + +export async function getServices( + environmentId: EnvironmentId, + namespace: string +) { + try { + const { data: services } = await axios.get( + buildUrl(environmentId, namespace) + ); + return services; + } catch (e) { + throw parseAxiosError(e as Error, 'Unable to retrieve services'); + } +} + +function buildUrl(environmentId: EnvironmentId, namespace: string) { + const url = `kubernetes/${environmentId}/namespaces/${namespace}/services`; + return url; +} diff --git a/app/kubernetes/react/views/networks/services/types.ts b/app/kubernetes/react/views/networks/services/types.ts new file mode 100644 index 000000000..ab1d24179 --- /dev/null +++ b/app/kubernetes/react/views/networks/services/types.ts @@ -0,0 +1,33 @@ +export interface Port { + Name: string; + Protocol: string; + Port: number; + TargetPort: number; + NodePort?: number; +} + +export interface IngressIP { + IP: string; +} + +export interface LoadBalancer { + Ingress: IngressIP[]; +} + +export interface Status { + LoadBalancer: LoadBalancer; +} + +export interface Service { + Annotations?: Document; + CreationTimestamp?: string; + Labels?: Document; + Name: string; + Namespace: string; + UID: string; + AllocateLoadBalancerNodePorts?: boolean; + Ports: Port[]; + Selector?: Document; + Type: string; + Status?: Status; +} diff --git a/app/portainer/environments/types.ts b/app/portainer/environments/types.ts index fa0c33f39..286022f82 100644 --- a/app/portainer/environments/types.ts +++ b/app/portainer/environments/types.ts @@ -57,8 +57,24 @@ export interface KubernetesSnapshot { NodeCount: number; } +export type IngressClass = { + Name: string; + Type: string; +}; + +export interface KubernetesConfiguration { + UseLoadBalancer?: boolean; + UseServerMetrics?: boolean; + EnableResourceOverCommit?: boolean; + ResourceOverCommitPercentage?: number; + RestrictDefaultNamespace?: boolean; + IngressClasses: IngressClass[]; + IngressAvailabilityPerNamespace: boolean; +} + export interface KubernetesSettings { Snapshots?: KubernetesSnapshot[] | null; + Configuration: KubernetesConfiguration; } export type EnvironmentEdge = { diff --git a/app/react-tools/test-mocks.ts b/app/react-tools/test-mocks.ts index 68cf61392..59e8fddb3 100644 --- a/app/react-tools/test-mocks.ts +++ b/app/react-tools/test-mocks.ts @@ -70,7 +70,13 @@ export function createMockEnvironment(): Environment { Status: 1, URL: 'url', Snapshots: [], - Kubernetes: { Snapshots: [] }, + Kubernetes: { + Snapshots: [], + Configuration: { + IngressClasses: [], + IngressAvailabilityPerNamespace: false, + }, + }, EdgeKey: '', Id: 3, UserTrusted: false, @@ -92,8 +98,6 @@ export function createMockEnvironment(): Environment { enableHostManagementFeatures: false, }, Gpus: [], - Agent: { - Version: '', - }, + Agent: { Version: '1.0.0' }, }; } diff --git a/app/react/components/Link.tsx b/app/react/components/Link.tsx index d41cec874..92c96d238 100644 --- a/app/react/components/Link.tsx +++ b/app/react/components/Link.tsx @@ -1,20 +1,25 @@ -import { PropsWithChildren } from 'react'; +import { PropsWithChildren, AnchorHTMLAttributes } from 'react'; import { UISref, UISrefProps } from '@uirouter/react'; +import clsx from 'clsx'; interface Props { title?: string; + target?: AnchorHTMLAttributes['target']; } export function Link({ title = '', + className, children, ...props }: PropsWithChildren & UISrefProps) { return ( // eslint-disable-next-line react/jsx-props-no-spreading - + {/* eslint-disable-next-line jsx-a11y/anchor-is-valid */} - {children} + + {children} + ); } diff --git a/app/react/components/datatables/Datatable.tsx b/app/react/components/datatables/Datatable.tsx index edb22a6a8..657af647d 100644 --- a/app/react/components/datatables/Datatable.tsx +++ b/app/react/components/datatables/Datatable.tsx @@ -141,7 +141,11 @@ export function Datatable< {isTitleVisible(titleOptions) && ( - + {renderTableActions && ( diff --git a/app/react/components/datatables/Filter.tsx b/app/react/components/datatables/Filter.tsx index d31c62261..69696eca1 100644 --- a/app/react/components/datatables/Filter.tsx +++ b/app/react/components/datatables/Filter.tsx @@ -3,42 +3,22 @@ import { useMemo } from 'react'; import { Menu, MenuButton, MenuPopover } from '@reach/menu-button'; import { ColumnInstance } from 'react-table'; -export function DefaultFilter({ - column: { filterValue, setFilter, preFilteredRows, id }, -}: { - column: ColumnInstance; -}) { - const options = useMemo(() => { - const options = new Set(); - preFilteredRows.forEach((row) => { - options.add(row.values[id]); - }); - - return Array.from(options); - }, [id, preFilteredRows]); - - return ( - - ); -} +export const DefaultFilter = filterHOC('Filter by state'); interface MultipleSelectionFilterProps { options: string[]; value: string[]; filterKey: string; onChange: (value: string[]) => void; + menuTitle?: string; } -function MultipleSelectionFilter({ +export function MultipleSelectionFilter({ options, value = [], filterKey, onChange, + menuTitle = 'Filter by state', }: MultipleSelectionFilterProps) { const enabled = value.length > 0; return ( @@ -59,7 +39,7 @@ function MultipleSelectionFilter({
-
Filter by state
+
{menuTitle}
{options.map((option, index) => (
@@ -91,3 +71,28 @@ function MultipleSelectionFilter({ onChange([...value, option]); } } + +export function filterHOC(menuTitle: string) { + return function Filter({ + column: { filterValue, setFilter, preFilteredRows, id }, + }: { + column: ColumnInstance; + }) { + const options = useMemo(() => { + const options = new Set(); + preFilteredRows.forEach((row) => { + options.add(row.values[id]); + }); + return Array.from(options); + }, [id, preFilteredRows]); + return ( + + ); + }; +} diff --git a/app/react/components/form-components/FormError.tsx b/app/react/components/form-components/FormError.tsx index 04467d1b7..ed61bbe08 100644 --- a/app/react/components/form-components/FormError.tsx +++ b/app/react/components/form-components/FormError.tsx @@ -1,16 +1,17 @@ import { PropsWithChildren } from 'react'; +import clsx from 'clsx'; import { Icon } from '@@/Icon'; -export function FormError({ children }: PropsWithChildren) { +interface Props { + className?: string; +} + +export function FormError({ children, className }: PropsWithChildren) { return ( -
- - {children} -
+

+ + {children} +

); } diff --git a/app/react/docker/containers/ListView/ContainersDatatable/ContainersDatatable.tsx b/app/react/docker/containers/ListView/ContainersDatatable/ContainersDatatable.tsx index cb53b0cd3..ec9b5398f 100644 --- a/app/react/docker/containers/ListView/ContainersDatatable/ContainersDatatable.tsx +++ b/app/react/docker/containers/ListView/ContainersDatatable/ContainersDatatable.tsx @@ -57,7 +57,7 @@ export function ContainersDatatable({ (namespace ? getConfigMaps(environmentId, namespace) : []), + { + onError: (err) => { + notifyError('Failure', err as Error, 'Unable to get configurations'); + }, + enabled: !!namespace, + } + ); +} diff --git a/app/react/kubernetes/configs/service.ts b/app/react/kubernetes/configs/service.ts new file mode 100644 index 000000000..7ebc55ced --- /dev/null +++ b/app/react/kubernetes/configs/service.ts @@ -0,0 +1,18 @@ +import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { EnvironmentId } from '@/portainer/environments/types'; + +import { Configuration } from './types'; + +export async function getConfigMaps( + environmentId: EnvironmentId, + namespace: string +) { + try { + const { data: configmaps } = await axios.get( + `kubernetes/${environmentId}/namespaces/${namespace}/configmaps` + ); + return configmaps; + } catch (e) { + throw parseAxiosError(e as Error, 'Unable to retrieve configmaps'); + } +} diff --git a/app/react/kubernetes/configs/types.ts b/app/react/kubernetes/configs/types.ts new file mode 100644 index 000000000..ef0c95269 --- /dev/null +++ b/app/react/kubernetes/configs/types.ts @@ -0,0 +1,17 @@ +export interface Configuration { + Id: string; + Name: string; + Type: number; + Namespace: string; + CreationDate: Date; + + ConfigurationOwner: string; + + Used: boolean; + // Applications: any[]; + Data: Document; + Yaml: string; + + SecretType?: string; + IsRegistrySecret?: boolean; +} diff --git a/app/react/kubernetes/namespaces/queries.ts b/app/react/kubernetes/namespaces/queries.ts new file mode 100644 index 000000000..13657f4ea --- /dev/null +++ b/app/react/kubernetes/namespaces/queries.ts @@ -0,0 +1,30 @@ +import { useQuery } from 'react-query'; + +import { EnvironmentId } from '@/portainer/environments/types'; +import { error as notifyError } from '@/portainer/services/notifications'; + +import { getNamespaces, getNamespace } from './service'; + +export function useNamespaces(environmentId: EnvironmentId) { + return useQuery( + ['environments', environmentId, 'kubernetes', 'namespaces'], + () => getNamespaces(environmentId), + { + onError: (err) => { + notifyError('Failure', err as Error, 'Unable to get namespaces.'); + }, + } + ); +} + +export function useNamespace(environmentId: EnvironmentId, namespace: string) { + return useQuery( + ['environments', environmentId, 'kubernetes', 'namespaces', namespace], + () => getNamespace(environmentId, namespace), + { + onError: (err) => { + notifyError('Failure', err as Error, 'Unable to get namespace.'); + }, + } + ); +} diff --git a/app/react/kubernetes/namespaces/service.ts b/app/react/kubernetes/namespaces/service.ts new file mode 100644 index 000000000..74cd14e0d --- /dev/null +++ b/app/react/kubernetes/namespaces/service.ts @@ -0,0 +1,39 @@ +import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { EnvironmentId } from '@/portainer/environments/types'; + +import { Namespaces } from './types'; + +export async function getNamespace( + environmentId: EnvironmentId, + namespace: string +) { + try { + const { data: ingress } = await axios.get( + buildUrl(environmentId, namespace) + ); + return ingress; + } catch (e) { + throw parseAxiosError(e as Error, 'Unable to retrieve network details'); + } +} + +export async function getNamespaces(environmentId: EnvironmentId) { + try { + const { data: ingresses } = await axios.get( + buildUrl(environmentId) + ); + return ingresses; + } catch (e) { + throw parseAxiosError(e as Error, 'Unable to retrieve network details'); + } +} + +function buildUrl(environmentId: EnvironmentId, namespace?: string) { + let url = `kubernetes/${environmentId}/namespaces`; + + if (namespace) { + url += `/${namespace}`; + } + + return url; +} diff --git a/app/react/kubernetes/namespaces/types.ts b/app/react/kubernetes/namespaces/types.ts new file mode 100644 index 000000000..20436c306 --- /dev/null +++ b/app/react/kubernetes/namespaces/types.ts @@ -0,0 +1,6 @@ +export interface Namespaces { + [key: string]: { + IsDefault: boolean; + IsSystem: boolean; + }; +} diff --git a/app/react/kubernetes/services/readme.md b/app/react/kubernetes/services/readme.md new file mode 100644 index 000000000..10ae44910 --- /dev/null +++ b/app/react/kubernetes/services/readme.md @@ -0,0 +1,5 @@ +## Common Services + +This folder contains rest api services that are shared by different features within kubernetes. + +This includes api requests to the portainer backend, and also requests to the kubernetes api. diff --git a/app/react/sidebar/KubernetesSidebar/KubernetesSidebar.tsx b/app/react/sidebar/KubernetesSidebar/KubernetesSidebar.tsx index 22819b284..a2cb09f64 100644 --- a/app/react/sidebar/KubernetesSidebar/KubernetesSidebar.tsx +++ b/app/react/sidebar/KubernetesSidebar/KubernetesSidebar.tsx @@ -3,6 +3,7 @@ import { Box, Edit, Layers, Lock, Server } from 'react-feather'; import { EnvironmentId } from '@/portainer/environments/types'; import { Authorized } from '@/portainer/hooks/useUser'; import Helm from '@/assets/ico/vendor/helm.svg?c'; +import Route from '@/assets/ico/route.svg?c'; import { DashboardLink } from '../items/DashboardLink'; import { SidebarItem } from '../SidebarItem'; @@ -69,6 +70,14 @@ export function KubernetesSidebar({ environmentId }: Props) { data-cy="k8sSidebar-applications" /> + + @@ -110,7 +119,7 @@ export function KubernetesSidebar({ environmentId }: Props) { > diff --git a/configmaps_and_secrets.go b/configmaps_and_secrets.go new file mode 100644 index 000000000..b266e3a2f --- /dev/null +++ b/configmaps_and_secrets.go @@ -0,0 +1,50 @@ +package kubernetes + +import ( + "net/http" + + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" + "github.com/portainer/libhttp/response" +) + +// @id GetKubernetesConfigMaps +// @summary Fetches a list of config maps for a given namespace +// @description Fetches a list of config maps for a given namespace +// classes from the kubernetes api +// @description **Access policy**: authenticated +// @tags kubernetes +// @security ApiKeyAuth +// @security jwt +// @accept json +// @produce json +// @success 200 "Success" +// @failure 400 "Invalid request" +// @failure 401 "Unauthorized" +// @failure 403 "Permission denied" +// @failure 404 "Environment(Endpoint) not found" +// @failure 500 "Server error" +// @router /kubernetes/{id}/namespaces/{namespace}/configmaps [get] +func (handler *Handler) getKubernetesConfigMaps(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + cli := handler.KubernetesClient + + namespace, err := request.RetrieveRouteVariableValue(r, "namespace") + if err != nil { + return &httperror.HandlerError{ + StatusCode: http.StatusBadRequest, + Message: "Invalid namespace identifier route variable", + Err: err, + } + } + + configmaps, err := cli.GetConfigMapsAndSecrets(namespace) + if err != nil { + return &httperror.HandlerError{ + StatusCode: http.StatusInternalServerError, + Message: "Unable to retrieve nodes limits", + Err: err, + } + } + + return response.JSON(w, configmaps) +} diff --git a/ingresses.go b/ingresses.go new file mode 100644 index 000000000..156eea61f --- /dev/null +++ b/ingresses.go @@ -0,0 +1,550 @@ +package kubernetes + +import ( + "fmt" + "net/http" + + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" + "github.com/portainer/libhttp/response" + portaineree "github.com/portainer/portainer-ee/api" + "github.com/portainer/portainer-ee/api/database/models" + portainerDsErrors "github.com/portainer/portainer/api/dataservices/errors" +) + +// @id GetKubernetesIngressControllers +// @summary Fetches a list of ingress controllers with classes +// @description Fetches a list of ingress controllers which have associated +// classes from the kubernetes api +// @description **Access policy**: authenticated +// @tags kubernetes +// @security ApiKeyAuth +// @security jwt +// @accept json +// @produce json +// @success 200 "Success" +// @failure 400 "Invalid request" +// @failure 401 "Unauthorized" +// @failure 403 "Permission denied" +// @failure 404 "Environment(Endpoint) not found" +// @failure 500 "Server error" +// @router /kubernetes/{id}/ingresscontrollers [get] +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, + } + } + + endpoint, err := handler.DataStore.Endpoint().Endpoint(portaineree.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, + } + } 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, + } + } + + 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() + existingClasses := endpoint.Kubernetes.Configuration.IngressClasses + for i := range controllers { + controllers[i].Availability = true + controllers[i].New = true + + // Check if the controller is blocked globally. + for _, a := range existingClasses { + controllers[i].New = false + if controllers[i].ClassName != a.Name { + continue + } + controllers[i].New = false + + // Skip over non-global blocks. + if len(a.BlockedNamespaces) > 0 { + continue + } + + if controllers[i].ClassName == a.Name { + controllers[i].Availability = !a.Blocked + } + } + // TODO: Update existingClasses to take care of New and remove no longer + // existing classes. + } + return response.JSON(w, controllers) +} + +// @id GetKubernetesIngressControllersByNamespace +// @summary Fetches a list of ingress controllers with classes allowed in a +// namespace +// @description Fetches a list of ingress controllers which have associated +// classes from the kubernetes api and have been allowed in a given namespace +// @description **Access policy**: authenticated +// @tags kubernetes +// @security ApiKeyAuth +// @security jwt +// @accept json +// @produce json +// @success 200 "Success" +// @failure 400 "Invalid request" +// @failure 401 "Unauthorized" +// @failure 403 "Permission denied" +// @failure 404 "Environment(Endpoint) not found" +// @failure 500 "Server error" +// @router /kubernetes/{id}/namespaces/{namespace}/ingresscontrollers [get] +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, + } + } + + endpoint, err := handler.DataStore.Endpoint().Endpoint(portaineree.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, + } + } 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, + } + } + + namespace, err := request.RetrieveRouteVariableValue(r, "namespace") + if err != nil { + return &httperror.HandlerError{ + StatusCode: http.StatusBadRequest, + Message: "Invalid namespace identifier route variable", + Err: 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() + existingClasses := endpoint.Kubernetes.Configuration.IngressClasses + for i := range controllers { + controllers[i].Availability = true + controllers[i].New = true + + // Check if the controller is blocked globally or in the current + // namespace. + for _, a := range existingClasses { + if controllers[i].ClassName != a.Name { + continue + } + controllers[i].New = false + + // If it's not blocked we're all done! + if !a.Blocked { + continue + } + + // Global blocks. + if len(a.BlockedNamespaces) == 0 { + controllers[i].Availability = false + continue + } + + // Also check the current namespace. + for _, ns := range a.BlockedNamespaces { + if namespace == ns { + controllers[i].Availability = false + } + } + } + // TODO: Update existingClasses to take care of New and remove no longer + // existing classes. + } + return response.JSON(w, controllers) +} + +// @id UpdateKubernetesIngressControllers +// @summary Updates a list of ingress controller permissions globally in a +// cluster +// @description Updates a list of ingress controller permissions to deny or +// allow their usage in a given cluster +// @description **Access policy**: authenticated +// @tags kubernetes +// @security ApiKeyAuth +// @security jwt +// @accept json +// @produce json +// @param body body models.K8sIngressControllers true "list of controllers to update" +// @success 200 "Success" +// @failure 400 "Invalid request" +// @failure 401 "Unauthorized" +// @failure 403 "Permission denied" +// @failure 404 "Environment(Endpoint) not found" +// @failure 500 "Server error" +// @router /kubernetes/{id}/ingresscontrollers [put] +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, + } + } + + endpoint, err := handler.DataStore.Endpoint().Endpoint(portaineree.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, + } + } 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, + } + } + + var payload models.K8sIngressControllers + err = request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{ + StatusCode: http.StatusBadRequest, + Message: "Invalid request payload", + Err: 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 + } + } + } + endpoint.Kubernetes.Configuration.IngressClasses = classes + fmt.Printf("%#v\n", endpoint.Kubernetes.Configuration.IngressClasses) + err = handler.DataStore.Endpoint().UpdateEndpoint( + portaineree.EndpointID(endpointID), + endpoint, + ) + if err != nil { + return &httperror.HandlerError{ + StatusCode: http.StatusInternalServerError, + Message: "Unable to update the BlockedIngressClasses inside the database", + Err: err, + } + } + return nil +} + +// @id UpdateKubernetesIngressControllers +// @summary Updates a list of ingress controller permissions in a particular +// namespace in a particular cluster +// @description Updates a list of ingress controller permissions in a particular +// namespace in a particular cluster +// @description **Access policy**: authenticated +// @tags kubernetes +// @security ApiKeyAuth +// @security jwt +// @accept json +// @produce json +// @param body body models.K8sIngressControllers true "list of controllers to update" +// @success 200 "Success" +// @failure 400 "Invalid request" +// @failure 401 "Unauthorized" +// @failure 403 "Permission denied" +// @failure 404 "Environment(Endpoint) not found" +// @failure 500 "Server error" +// @router /kubernetes/{id}/namespaces/{namespace}/ingresscontrollers [put] +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, + } + } + + endpoint, err := handler.DataStore.Endpoint().Endpoint(portaineree.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, + } + } 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, + } + } + + namespace, err := request.RetrieveRouteVariableValue(r, "namespace") + if err != nil { + return &httperror.HandlerError{ + StatusCode: http.StatusBadRequest, + Message: "Invalid namespace identifier route variable", + Err: 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, + } + } + + classes := endpoint.Kubernetes.Configuration.IngressClasses +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 + } + + // 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 + } + } + classes[i].BlockedNamespaces = append( + classes[i].BlockedNamespaces, + namespace, + ) + } + } + } + endpoint.Kubernetes.Configuration.IngressClasses = classes + fmt.Printf("%#v\n", endpoint.Kubernetes.Configuration.IngressClasses) + err = handler.DataStore.Endpoint().UpdateEndpoint( + portaineree.EndpointID(endpointID), + endpoint, + ) + if err != nil { + return &httperror.HandlerError{ + StatusCode: http.StatusInternalServerError, + Message: "Unable to update the BlockedIngressClasses inside the database", + Err: err, + } + } + return nil +} + +// @id GetKubernetesIngresses +// @summary Fetches a list of ingresses in a namespace +// @description Fetches a list of ingresses in a namespace +// @description **Access policy**: authenticated +// @tags kubernetes +// @security ApiKeyAuth +// @security jwt +// @accept json +// @produce json +// @success 200 "Success" +// @failure 400 "Invalid request" +// @failure 401 "Unauthorized" +// @failure 403 "Permission denied" +// @failure 404 "Environment(Endpoint) not found" +// @failure 500 "Server error" +// @router /kubernetes/{id}/namespaces/{namespace}/ingresses [get] +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, + } + } + + 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 response.JSON(w, ingresses) +} + +// @id CreateKubernetesIngresses +// @summary Creates an ingress in a namespace +// @description Creates an ingress in a namespace +// @description **Access policy**: authenticated +// @tags kubernetes +// @security ApiKeyAuth +// @security jwt +// @accept json +// @produce json +// @param body body models.K8sIngressInfo true "ingress to create" +// @success 200 "Success" +// @failure 400 "Invalid request" +// @failure 401 "Unauthorized" +// @failure 403 "Permission denied" +// @failure 404 "Environment(Endpoint) not found" +// @failure 500 "Server error" +// @router /kubernetes/{id}/namespaces/{namespace}/ingresses [post] +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, + } + } + + var payload models.K8sIngressInfo + err = request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{ + StatusCode: http.StatusBadRequest, + Message: "Invalid request payload", + Err: 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 nil +} + +// @id DeleteKubernetesIngresses +// @summary Deletes an ingress in a namespace +// @description Fetches a list of ingresses in a namespace +// @description **Access policy**: authenticated +// @tags kubernetes +// @security ApiKeyAuth +// @security jwt +// @accept json +// @produce json +// @param body body models.K8sIngressDeleteRequests true "ingress to delete" +// @success 200 "Success" +// @failure 400 "Invalid request" +// @failure 401 "Unauthorized" +// @failure 403 "Permission denied" +// @failure 404 "Environment(Endpoint) not found" +// @failure 500 "Server error" +// @router /kubernetes/{id}/namespaces/{namespace}/ingresses/delete [post] +func (handler *Handler) deleteKubernetesIngresses(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + cli := handler.KubernetesClient + + var payload models.K8sIngressDeleteRequests + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return httperror.BadRequest("Invalid request payload", err) + } + + err = cli.DeleteIngresses(payload) + if err != nil { + return &httperror.HandlerError{ + StatusCode: http.StatusInternalServerError, + Message: "Unable to retrieve nodes limits", + Err: err, + } + } + return nil +} + +// @id UpdateKubernetesIngresses +// @summary Updates an ingress in a namespace +// @description Fetches a list of ingresses in a namespace +// @description **Access policy**: authenticated +// @tags kubernetes +// @security ApiKeyAuth +// @security jwt +// @accept json +// @produce json +// @param body body models.K8sIngressInfo true "ingress to update" +// @success 200 "Success" +// @failure 400 "Invalid request" +// @failure 401 "Unauthorized" +// @failure 403 "Permission denied" +// @failure 404 "Environment(Endpoint) not found" +// @failure 500 "Server error" +// @router /kubernetes/{id}/namespaces/{namespace}/ingresses [put] +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, + } + } + + var payload models.K8sIngressInfo + err = request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{ + StatusCode: http.StatusBadRequest, + Message: "Invalid request payload", + Err: 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 nil +} diff --git a/namespaces.go b/namespaces.go new file mode 100644 index 000000000..bb96ee967 --- /dev/null +++ b/namespaces.go @@ -0,0 +1,162 @@ +package kubernetes + +import ( + "net/http" + + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" + "github.com/portainer/libhttp/response" + "github.com/portainer/portainer-ee/api/database/models" +) + +// @id GetKubernetesNamespaces +// @summary Fetches a list of namespaces for a given cluster +// @description Fetches a list of namespaces for a given cluster +// @description **Access policy**: authenticated +// @tags kubernetes +// @security ApiKeyAuth +// @security jwt +// @accept json +// @produce json +// @success 200 "Success" +// @failure 400 "Invalid request" +// @failure 401 "Unauthorized" +// @failure 403 "Permission denied" +// @failure 404 "Environment(Endpoint) not found" +// @failure 500 "Server error" +// @router /kubernetes/{id}/namespaces [get] +func (handler *Handler) getKubernetesNamespaces(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + cli := handler.KubernetesClient + + namespaces, err := cli.GetNamespaces() + if err != nil { + return &httperror.HandlerError{ + StatusCode: http.StatusInternalServerError, + Message: "Unable to retrieve nodes limits", + Err: err, + } + } + + return response.JSON(w, namespaces) +} + +// @id CreateKubernetesNamespace +// @summary Creates a namespace in a given cluster +// @description Creates a namespace in a given cluster +// @description **Access policy**: authenticated +// @tags kubernetes +// @security ApiKeyAuth +// @security jwt +// @accept json +// @produce json +// @param body body models.K8sNamespaceInfo true "namespace to create" +// @success 200 "Success" +// @failure 400 "Invalid request" +// @failure 401 "Unauthorized" +// @failure 403 "Permission denied" +// @failure 404 "Environment(Endpoint) not found" +// @failure 500 "Server error" +// @router /kubernetes/{id}/namespaces [post] +func (handler *Handler) createKubernetesNamespace(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + cli := handler.KubernetesClient + + var payload models.K8sNamespaceInfo + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{ + StatusCode: http.StatusBadRequest, + Message: "Invalid request payload", + Err: err, + } + } + + err = cli.CreateNamespace(payload) + if err != nil { + return &httperror.HandlerError{ + StatusCode: http.StatusInternalServerError, + Message: "Unable to retrieve nodes limits", + Err: err, + } + } + return nil +} + +// @id DeleteKubernetesNamespaces +// @summary Delete a namespace from a given cluster +// @description **Access policy**: authenticated +// @tags kubernetes +// @security ApiKeyAuth +// @security jwt +// @accept json +// @produce json +// @success 200 "Success" +// @failure 400 "Invalid request" +// @failure 401 "Unauthorized" +// @failure 403 "Permission denied" +// @failure 404 "Environment(Endpoint) not found" +// @failure 500 "Server error" +// @router /kubernetes/{id}/namespace/{namespace} [delete] +func (handler *Handler) deleteKubernetesNamespaces(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + cli := handler.KubernetesClient + + namespace, err := request.RetrieveRouteVariableValue(r, "namespace") + if err != nil { + return &httperror.HandlerError{ + StatusCode: http.StatusBadRequest, + Message: "Invalid namespace identifier route variable", + Err: err, + } + } + + err = cli.DeleteNamespace(namespace) + if err != nil { + return &httperror.HandlerError{ + StatusCode: http.StatusInternalServerError, + Message: "Unable to retrieve nodes limits", + Err: err, + } + } + + return nil +} + +// @id UpdateKubernetesNamespace +// @summary Updates a namespace in a given cluster +// @description Updates a namespace in a given cluster +// @description **Access policy**: authenticated +// @tags kubernetes +// @security ApiKeyAuth +// @security jwt +// @accept json +// @produce json +// @param body body models.K8sNamespaceInfo true "namespace to update" +// @success 200 "Success" +// @failure 400 "Invalid request" +// @failure 401 "Unauthorized" +// @failure 403 "Permission denied" +// @failure 404 "Environment(Endpoint) not found" +// @failure 500 "Server error" +// @router /kubernetes/{id}/namespaces [put] +func (handler *Handler) updateKubernetesNamespace(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + cli := handler.KubernetesClient + + var payload models.K8sNamespaceInfo + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{ + StatusCode: http.StatusBadRequest, + Message: "Invalid request payload", + Err: err, + } + } + + err = cli.UpdateNamespace(payload) + if err != nil { + return &httperror.HandlerError{ + StatusCode: http.StatusInternalServerError, + Message: "Unable to retrieve nodes limits", + Err: err, + } + } + return nil +} diff --git a/services.go b/services.go new file mode 100644 index 000000000..53482ebf6 --- /dev/null +++ b/services.go @@ -0,0 +1,188 @@ +package kubernetes + +import ( + "net/http" + + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" + "github.com/portainer/libhttp/response" + "github.com/portainer/portainer-ee/api/database/models" +) + +// @id GetKubernetesServices +// @summary Fetches a list of services in a namespace +// @description Fetches a list of services in a namespace +// @description **Access policy**: authenticated +// @tags kubernetes +// @security ApiKeyAuth +// @security jwt +// @accept json +// @produce json +// @success 200 "Success" +// @failure 400 "Invalid request" +// @failure 401 "Unauthorized" +// @failure 403 "Permission denied" +// @failure 404 "Environment(Endpoint) not found" +// @failure 500 "Server error" +// @router /kubernetes/{id}/namespaces/{namespace}/services [get] +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, + } + } + + 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 response.JSON(w, services) +} + +// @id CreateKubernetesService +// @summary Creates an service in a namespace +// @description Creates an service in a namespace +// @description **Access policy**: authenticated +// @tags kubernetes +// @security ApiKeyAuth +// @security jwt +// @accept json +// @produce json +// @param body body models.K8sServiceInfo true "service to create" +// @success 200 "Success" +// @failure 400 "Invalid request" +// @failure 401 "Unauthorized" +// @failure 403 "Permission denied" +// @failure 404 "Environment(Endpoint) not found" +// @failure 500 "Server error" +// @router /kubernetes/{id}/namespaces/{namespace}/services [post] +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, + } + } + + var payload models.K8sServiceInfo + err = request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{ + StatusCode: http.StatusBadRequest, + Message: "Invalid request payload", + Err: 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 nil +} + +// @id DeleteKubernetesServices +// @summary Deletes a service in a namespace +// @description Deletes a service in a namespace +// @description **Access policy**: authenticated +// @tags kubernetes +// @security ApiKeyAuth +// @security jwt +// @accept json +// @produce json +// @param body body models.K8sServiceDeleteRequests true "service to delete" +// @success 200 "Success" +// @failure 400 "Invalid request" +// @failure 401 "Unauthorized" +// @failure 403 "Permission denied" +// @failure 404 "Environment(Endpoint) not found" +// @failure 500 "Server error" +// @router /kubernetes/{id}/namespaces/{namespace}/services/delete [post] +func (handler *Handler) deleteKubernetesServices(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + cli := handler.KubernetesClient + + var payload models.K8sServiceDeleteRequests + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{ + StatusCode: http.StatusBadRequest, + Message: "Invalid request payload", + Err: err, + } + } + + err = cli.DeleteServices(payload) + if err != nil { + return &httperror.HandlerError{ + StatusCode: http.StatusInternalServerError, + Message: "Unable to retrieve nodes limits", + Err: err, + } + } + return nil +} + +// @id UpdateKubernetesService +// @summary Updates an service in a namespace +// @description Updates an service in a namespace +// @description **Access policy**: authenticated +// @tags kubernetes +// @security ApiKeyAuth +// @security jwt +// @accept json +// @produce json +// @param body body models.K8sServiceInfo true "service to update" +// @success 200 "Success" +// @failure 400 "Invalid request" +// @failure 401 "Unauthorized" +// @failure 403 "Permission denied" +// @failure 404 "Environment(Endpoint) not found" +// @failure 500 "Server error" +// @router /kubernetes/{id}/namespaces/{namespace}/services [put] +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, + } + } + + var payload models.K8sServiceInfo + err = request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{ + StatusCode: http.StatusBadRequest, + Message: "Invalid request payload", + Err: 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 nil +}