feat(ingress): ingresses datatable with add/edit ingresses EE-2615 (#7672)

pull/7677/head
Prabhat Khera 2022-09-21 16:49:42 +12:00 committed by GitHub
parent 393d1fc91d
commit ef1d648c07
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
68 changed files with 4938 additions and 61 deletions

View File

@ -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"`
}
)

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -52,7 +52,9 @@
"IsEdgeDevice": false,
"Kubernetes": {
"Configuration": {
"EnableResourceOverCommit": false,
"IngressClasses": null,
"ResourceOverCommitPercentage": 0,
"RestrictDefaultNamespace": false,
"StorageClasses": null,
"UseLoadBalancer": false,

View File

@ -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)
}

View File

@ -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)
})
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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)

View File

@ -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
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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)

View File

@ -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;
}

View File

@ -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);
},
]);

View File

@ -15,6 +15,7 @@ class KubernetesConfigurationConverter {
});
res.ConfigurationOwner = secret.ConfigurationOwner;
res.IsRegistrySecret = secret.IsRegistrySecret;
res.SecretType = secret.SecretType;
return res;
}

View File

@ -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();

View File

@ -43,6 +43,7 @@ export class KubernetesIngressConverter {
if (idx >= 0) {
res.Hosts.splice(idx, 1, '');
}
res.TLS = data.spec.tls;
return res;
}

View File

@ -8,6 +8,7 @@ export function KubernetesIngress() {
// PreviousHost: undefined, // only use for RP ingress host edit
Paths: [],
IngressClassName: '',
TLS: [],
};
}

View File

@ -14,6 +14,7 @@ const _KubernetesConfiguration = Object.freeze({
Used: false,
Applications: [],
Data: {},
SecretType: '',
});
export class KubernetesConfiguration {

View File

@ -9,6 +9,7 @@ const _KubernetesApplicationSecret = Object.freeze({
ConfigurationOwner: '',
Yaml: '',
Data: [],
SecretType: '',
});
export class KubernetesApplicationSecret {

View File

@ -57,6 +57,7 @@ const _KubernetesIngressServiceRoute = Object.freeze({
IngressName: '',
Path: '',
ServiceName: '',
TLSCert: '',
});
export class KubernetesIngressServiceRoute {

View File

@ -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;

View File

@ -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<string>(params.namespace || '');
const [ingressRule, setIngressRule] = useState<Rule>({} as Rule);
const [errors, setErrors] = useState<Record<string, ReactNode>>(
{} as Record<string, string>
);
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<string, number>,
Record<string, string>
] => {
const ruleCounterByNamespace: Record<string, number> = {};
const hostWithTLS: Record<string, string> = {};
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<string>[] = [
{ 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<string>[] = [
{ 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<string>[] = [
{ 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 (
<>
<PageHeader
title={isEdit ? 'Edit ingress' : 'Add ingress'}
breadcrumbs={[
{
link: 'kubernetes.ingresses',
label: 'Ingresses',
},
{
label: isEdit ? 'Edit ingress' : 'Add ingress',
},
]}
/>
<div className="row ingress-rules">
<div className="col-sm-12">
<IngressForm
environmentID={environmentId}
isLoading={isLoading}
isEdit={isEdit}
rule={ingressRule}
ingressClassOptions={ingressClassOptions}
errors={errors}
servicePorts={servicePorts}
tlsOptions={tlsOptions}
serviceOptions={serviceOptions}
addNewIngressHost={addNewIngressHost}
handleTLSChange={handleTLSChange}
handleHostChange={handleHostChange}
handleIngressChange={handleIngressChange}
handlePathChange={handlePathChange}
addNewIngressRoute={addNewIngressRoute}
removeIngressHost={removeIngressHost}
removeIngressRoute={removeIngressRoute}
addNewAnnotation={addNewAnnotation}
removeAnnotation={removeAnnotation}
reloadTLSCerts={reloadTLSCerts}
handleAnnotationChange={handleAnnotationChange}
namespace={namespace}
handleNamespaceChange={handleNamespaceChange}
namespacesOptions={namespacesOptions}
/>
</div>
{namespace && !isLoading && (
<div className="col-sm-12">
<Button
onClick={() => handleCreateIngressRules()}
disabled={Object.keys(errors).length > 0}
>
{isEdit ? 'Update' : 'Create'}
</Button>
</div>
)}
</div>
</>
);
function validate(
ingressRule: Rule,
ingressNames: string[],
serviceOptions: Option<string>[],
existingIngressClass: boolean
) {
const errors: Record<string, ReactNode> = {};
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`] = (
<span>
Currently set to {path.ServiceName}, which does not exist. You can
create a service with this name for a particular deployment via{' '}
<Link
to="kubernetes.applications"
params={{ id: environmentId }}
className="text-primary"
target="_blank"
>
Applications
</Link>
, and on returning here it will be picked up.
</span>
);
}
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');
},
}
);
}
}
}

View File

@ -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<string, string[]> = {
nginx: ['ImplementationSpecific', 'Prefix', 'Exact'],
traefik: ['Prefix', 'Exact'],
other: ['Prefix', 'Exact'],
};
const PlaceholderAnnotations: Record<string, string[]> = {
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<string, ReactNode>;
isLoading: boolean;
isEdit: boolean;
namespace: string;
servicePorts: ServicePorts;
ingressClassOptions: Option<string>[];
serviceOptions: Option<string>[];
tlsOptions: Option<string>[];
namespacesOptions: Option<string>[];
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 <div>Loading...</div>;
}
const hasNoHostRule = rule.Hosts?.some((host) => host.NoHost);
const placeholderAnnotation =
PlaceholderAnnotations[rule.IngressType || 'other'];
const pathTypes = PathTypes[rule.IngressType || 'other'];
return (
<Widget>
<WidgetTitle icon="svg-route" title="Ingress" />
<WidgetBody key={rule.Key + rule.Namespace}>
<div className="row">
<div className="form-horizontal">
<div className="form-group">
<label
className="control-label text-muted col-sm-3 col-lg-2 required"
htmlFor="namespace"
>
Namespace
</label>
<div className="col-sm-4">
{isEdit ? (
namespace
) : (
<Select
name="namespaces"
options={namespacesOptions || []}
onChange={(e) => handleNamespaceChange(e.target.value)}
defaultValue={namespace}
disabled={isEdit}
/>
)}
</div>
</div>
</div>
</div>
{namespace && (
<div className="row">
<div className="form-horizontal">
<div className="form-group">
<label
className="control-label text-muted col-sm-3 col-lg-2 required"
htmlFor="ingress_name"
>
Ingress name
</label>
<div className="col-sm-4">
{isEdit ? (
rule.IngressName
) : (
<input
name="ingress_name"
type="text"
className="form-control"
placeholder="Ingress name"
defaultValue={rule.IngressName}
onChange={(e: ChangeEvent<HTMLInputElement>) =>
handleIngressChange('IngressName', e.target.value)
}
disabled={isEdit}
/>
)}
{errors.ingressName && !isEdit && (
<FormError className="mt-1 error-inline">
{errors.ingressName}
</FormError>
)}
</div>
</div>
<div className="form-group" key={rule.IngressClassName}>
<label
className="control-label text-muted col-sm-3 col-lg-2 required"
htmlFor="ingress_class"
>
Ingress class
</label>
<div className="col-sm-4">
<Select
name="ingress_class"
className="form-control"
placeholder="Ingress name"
defaultValue={rule.IngressClassName}
onChange={(e: ChangeEvent<HTMLSelectElement>) =>
handleIngressChange('IngressClassName', e.target.value)
}
options={ingressClassOptions}
/>
{errors.className && (
<FormError className="mt-1 error-inline">
{errors.className}
</FormError>
)}
</div>
</div>
</div>
<div className="col-sm-12 px-0 text-muted !mb-0">
<div className="mb-2">Annotations</div>
<p className="vertical-center text-muted small">
<Icon icon="info" mode="primary" feather />
<span>
You can specify{' '}
<a
href="https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations/"
target="_black"
>
annotations
</a>{' '}
for the object. See further Kubernetes documentation on{' '}
<a
href="https://kubernetes.io/docs/reference/labels-annotations-taints/"
target="_black"
>
well-known annotations
</a>
.
</span>
</p>
</div>
{rule?.Annotations && (
<Annotations
placeholder={placeholderAnnotation}
annotations={rule.Annotations}
handleAnnotationChange={handleAnnotationChange}
removeAnnotation={removeAnnotation}
errors={errors}
/>
)}
<div className="col-sm-12 p-0 anntation-actions">
<Button
className="btn btn-sm btn-light mb-2 !ml-0"
onClick={() => addNewAnnotation()}
icon={Plus}
title="Use annotations to configure options for an ingress. Review Nginx or Traefik documentation to find the annotations supported by your choice of ingress type."
>
{' '}
add annotation
</Button>
{rule.IngressType === 'nginx' && (
<>
<Button
className="btn btn-sm btn-light mb-2 ml-2"
onClick={() => addNewAnnotation('rewrite')}
icon={Plus}
title="When the exposed URLs for your applications differ from the specified paths in the ingress, use the rewrite target annotation to denote the path to redirect to."
>
{' '}
add rewrite annotation
</Button>
<Button
className="btn btn-sm btn-light mb-2 ml-2"
onClick={() => addNewAnnotation('regex')}
icon={Plus}
title="When the exposed URLs for your applications differ from the specified paths in the ingress, use the rewrite target annotation to denote the path to redirect to."
>
add regular expression annotation
</Button>
</>
)}
</div>
<div className="col-sm-12 px-0 text-muted">Rules</div>
</div>
)}
{namespace &&
rule?.Hosts?.map((host, hostIndex) => (
<div className="row mb-5 rule bordered" key={host.Key}>
<div className="col-sm-12">
<div className="row mt-5 rule-actions">
<div className="col-sm-3 p-0">
{!host.NoHost ? 'Rule' : 'Fallback rule'}
</div>
<div className="col-sm-9 p-0 text-right">
{!host.NoHost && (
<Button
className="btn btn-light btn-sm"
onClick={() => reloadTLSCerts()}
icon={RefreshCw}
>
Reload TLS secrets
</Button>
)}
<Button
className="btn btn-sm btn-dangerlight ml-2"
type="button"
data-cy={`k8sAppCreate-rmHostButton_${hostIndex}`}
onClick={() => removeIngressHost(hostIndex)}
disabled={rule.Hosts.length === 1}
icon={Trash2}
>
Remove rule
</Button>
</div>
</div>
{!host.NoHost && (
<div className="row">
<div className="form-group !pl-0 col-sm-6 col-lg-4 !pr-2">
<div className="input-group input-group-sm">
<span className="input-group-addon required">
Hostname
</span>
<input
name={`ingress_host_${hostIndex}`}
type="text"
className="form-control form-control-sm"
placeholder="e.g. example.com"
defaultValue={host.Host}
onChange={(e: ChangeEvent<HTMLInputElement>) =>
handleHostChange(hostIndex, e.target.value)
}
/>
</div>
{errors[`hosts[${hostIndex}].host`] && (
<FormError className="mt-1 !mb-0">
{errors[`hosts[${hostIndex}].host`]}
</FormError>
)}
</div>
<div className="form-group !pr-0 col-sm-6 col-lg-4 !pl-2">
<div className="input-group input-group-sm">
<span className="input-group-addon">TLS secret</span>
<Select
key={tlsOptions.toString() + host.Secret}
name={`ingress_tls_${hostIndex}`}
options={tlsOptions}
onChange={(e: ChangeEvent<HTMLSelectElement>) =>
handleTLSChange(hostIndex, e.target.value)
}
defaultValue={host.Secret}
/>
</div>
</div>
<p className="vertical-center text-muted small whitespace-nowrap col-sm-12 !p-0">
<Icon icon="info" mode="primary" size="md" feather />
<span>
Add a secret via{' '}
<Link
to="kubernetes.configurations"
params={{ id: environmentID }}
className="text-primary"
target="_blank"
>
ConfigMaps &amp; Secrets
</Link>
{', '}
then select &apos;Reload TLS secrets&apos; above to
populate the dropdown with your changes.
</span>
</p>
</div>
)}
{host.NoHost && (
<p className="vertical-center text-muted small whitespace-nowrap col-sm-12 !p-0">
<Icon icon="info" mode="primary" size="md" feather />A
fallback rule has no host specified. This rule only applies
when an inbound request has a hostname that does not match
with any of your other rules.
</p>
)}
<div className="row">
<div className="col-sm-12 px-0 !mb-0 mt-2 text-muted">
Paths
</div>
</div>
{host.Paths.map((path, pathIndex) => (
<div
className="mt-5 !mb-5 row path"
key={`path_${path.Key}}`}
>
<div className="form-group !pl-0 col-sm-3 col-xl-2 !m-0">
<div className="input-group input-group-sm">
<span className="input-group-addon required">
Service
</span>
<Select
key={serviceOptions.toString() + path.ServiceName}
name={`ingress_service_${hostIndex}_${pathIndex}`}
options={serviceOptions}
onChange={(e: ChangeEvent<HTMLSelectElement>) =>
handlePathChange(
hostIndex,
pathIndex,
'ServiceName',
e.target.value
)
}
defaultValue={path.ServiceName}
/>
</div>
{errors[
`hosts[${hostIndex}].paths[${pathIndex}].servicename`
] && (
<FormError className="mt-1 !mb-0 error-inline">
{
errors[
`hosts[${hostIndex}].paths[${pathIndex}].servicename`
]
}
</FormError>
)}
</div>
<div className="form-group !pl-0 col-sm-2 col-xl-2 !m-0">
{servicePorts && (
<>
<div className="input-group input-group-sm">
<span className="input-group-addon required">
Service port
</span>
<Select
key={servicePorts.toString() + path.ServicePort}
name={`ingress_servicePort_${hostIndex}_${pathIndex}`}
options={
path.ServiceName &&
servicePorts[path.ServiceName]
? servicePorts[path.ServiceName]
: [
{
label: 'Select port',
value: '',
},
]
}
onChange={(e: ChangeEvent<HTMLSelectElement>) =>
handlePathChange(
hostIndex,
pathIndex,
'ServicePort',
e.target.value
)
}
defaultValue={path.ServicePort}
/>
</div>
{errors[
`hosts[${hostIndex}].paths[${pathIndex}].serviceport`
] && (
<FormError className="mt-1 !mb-0">
{
errors[
`hosts[${hostIndex}].paths[${pathIndex}].serviceport`
]
}
</FormError>
)}
</>
)}
</div>
<div className="form-group !pl-0 col-sm-3 col-xl-2 !m-0">
<div className="input-group input-group-sm">
<span className="input-group-addon required">
Path type
</span>
<Select
key={servicePorts.toString() + path.PathType}
name={`ingress_pathType_${hostIndex}_${pathIndex}`}
options={
pathTypes
? pathTypes.map((type) => ({
label: type,
value: type,
}))
: []
}
onChange={(e: ChangeEvent<HTMLSelectElement>) =>
handlePathChange(
hostIndex,
pathIndex,
'PathType',
e.target.value
)
}
defaultValue={path.PathType}
/>
</div>
{errors[
`hosts[${hostIndex}].paths[${pathIndex}].pathType`
] && (
<FormError className="mt-1 !mb-0">
{
errors[
`hosts[${hostIndex}].paths[${pathIndex}].pathType`
]
}
</FormError>
)}
</div>
<div className="form-group !pl-0 col-sm-3 col-xl-3 !m-0">
<div className="input-group input-group-sm">
<span className="input-group-addon required">Path</span>
<input
className="form-control"
name={`ingress_route_${hostIndex}-${pathIndex}`}
placeholder="/example"
data-pattern="/^(\/?[a-zA-Z0-9]+([a-zA-Z0-9-/_]*[a-zA-Z0-9])?|[a-zA-Z0-9]+)|(\/){1}$/"
data-cy={`k8sAppCreate-route_${hostIndex}-${pathIndex}`}
defaultValue={path.Route}
onChange={(e: ChangeEvent<HTMLInputElement>) =>
handlePathChange(
hostIndex,
pathIndex,
'Route',
e.target.value
)
}
/>
</div>
{errors[
`hosts[${hostIndex}].paths[${pathIndex}].path`
] && (
<FormError className="mt-1 !mb-0">
{
errors[
`hosts[${hostIndex}].paths[${pathIndex}].path`
]
}
</FormError>
)}
</div>
<div className="form-group !pl-0 col-sm-1 !m-0">
<Button
className="btn btn-sm btn-dangerlight btn-only-icon !ml-0 vertical-center"
type="button"
data-cy={`k8sAppCreate-rmPortButton_${hostIndex}-${pathIndex}`}
onClick={() => removeIngressRoute(hostIndex, pathIndex)}
disabled={host.Paths.length === 1}
icon={Trash2}
/>
</div>
</div>
))}
<div className="row mt-5">
<Button
className="btn btn-sm btn-light !ml-0"
type="button"
onClick={() => addNewIngressRoute(hostIndex)}
icon={Plus}
>
Add path
</Button>
</div>
</div>
</div>
))}
{namespace && (
<div className="row p-0 rules-action">
<div className="col-sm-12 p-0 vertical-center">
<Button
className="btn btn-sm btn-light !ml-0"
type="button"
onClick={() => addNewIngressHost()}
icon={Plus}
>
Add new host
</Button>
<Button
className="btn btn-sm btn-light ml-2"
type="button"
onClick={() => addNewIngressHost(true)}
disabled={hasNoHostRule}
icon={Plus}
>
Add fallback rule
</Button>
<Tooltip message="A fallback rule will be applied to all requests that do not match any of the defined hosts." />
</div>
</div>
)}
</WidgetBody>
</Widget>
);
}

View File

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

View File

@ -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<string>[];
}

View File

@ -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<string, string> = {};
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<string, string>
): 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;
}

View File

@ -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 (
<Datatable
dataset={result.data || []}
storageKey="ingressClassesNameSpace"
columns={columns}
settingsStore={settings}
isLoading={result.isLoading}
emptyContentLabel="No supported ingresses found"
titleOptions={{
icon: 'svg-route',
title: 'Ingresses',
}}
getRowId={(row) => row.Name + row.Type + row.Namespace}
renderTableActions={tableActions}
/>
);
function tableActions(selectedFlatRows: Ingress[]) {
return (
<div className="ingressDatatable-actions">
<Authorized authorizations="AzureContainerGroupDelete">
<Button
className="btn-wrapper"
color="dangerlight"
disabled={selectedFlatRows.length === 0}
onClick={() =>
handleRemoveClick(
selectedFlatRows.map((row) => ({
Name: row.Name,
Namespace: row.Namespace,
}))
)
}
icon={Trash2}
>
Remove
</Button>
</Authorized>
<Authorized authorizations="K8sIngressesAdd">
<Link to="kubernetes.ingresses.create" className="space-left">
<Button
icon={Plus}
className="btn-wrapper vertical-center"
color="secondary"
>
Add with form
</Button>
</Link>
</Authorized>
<Authorized authorizations="K8sApplicationsW">
<Link to="kubernetes.deploy" className="space-left">
<Button icon={Plus} className="btn-wrapper">
Create from manifest
</Button>
</Link>
</Authorized>
</div>
);
}
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;
}
}

View File

@ -0,0 +1,11 @@
import { Column } from 'react-table';
import { Ingress } from '../../types';
export const className: Column<Ingress> = {
Header: 'Class Name',
accessor: 'ClassName',
id: 'className',
disableFilters: true,
canHide: true,
};

View File

@ -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], []);
}

View File

@ -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 (
<a
href={`${isHttp ? 'http' : 'https'}://${host}${path}`}
target="_blank"
rel="noreferrer"
>
{`${isHttp ? 'http' : 'https'}://${host}${path}`}
</a>
);
}
export const ingressRules: Column<Ingress> = {
Header: 'Rules and Paths',
accessor: 'Paths',
Cell: ({ row }: CellProps<Ingress, Path[]>) => {
const results = row.original.Paths?.map((path: Path) => {
const isHttp = isHTTP(row.original.TLS || [], path.Host);
return (
<div key={`${path.Host}${path.Path}${path.ServiceName}:${path.Port}`}>
{link(path.Host, path.Path, isHttp)}
<span className="px-2">
<Icon icon="arrow-right" feather />
</span>
{`${path.ServiceName}:${path.Port}`}
</div>
);
});
return results || <div />;
},
id: 'ingressRules',
disableFilters: true,
canHide: true,
disableSortBy: true,
};

View File

@ -0,0 +1,26 @@
import { CellProps, Column } from 'react-table';
import { Link } from '@@/Link';
import { Ingress } from '../../types';
export const name: Column<Ingress> = {
Header: 'Name',
accessor: 'Name',
Cell: ({ row }: CellProps<Ingress>) => (
<Link
to="kubernetes.ingresses.edit"
params={{
uid: row.original.UID,
namespace: row.original.Namespace,
name: row.original.Name,
}}
title={row.original.Name}
>
{row.original.Name}
</Link>
),
id: 'name',
disableFilters: true,
canHide: true,
};

View File

@ -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<Ingress> = {
Header: 'Namespace',
accessor: 'Namespace',
Cell: ({ row }: CellProps<Ingress>) => (
<Link
to="kubernetes.resourcePools.resourcePool"
params={{
id: row.original.Namespace,
}}
title={row.original.Namespace}
>
{row.original.Namespace}
</Link>
),
id: 'namespace',
disableFilters: false,
canHide: true,
Filter: filterHOC('Filter by namespace'),
filter: (rows: Row<Ingress>[], filterValue, filters) => {
if (filters.length === 0) {
return rows;
}
return rows.filter((r) => filters.includes(r.original.Namespace));
},
};

View File

@ -0,0 +1,11 @@
import { Column } from 'react-table';
import { Ingress } from '../../types';
export const type: Column<Ingress> = {
Header: 'Type',
accessor: 'Type',
id: 'type',
disableFilters: true,
canHide: true,
};

View File

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

View File

@ -0,0 +1,20 @@
import { PageHeader } from '@@/PageHeader';
import { IngressDataTable } from './IngressDataTable';
export function IngressesDatatableView() {
return (
<>
<PageHeader
title="Ingresses"
breadcrumbs={[
{
label: 'Ingresses',
},
]}
reload
/>
<IngressDataTable />
</>
);
}

View File

@ -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<string, ReactNode>;
placeholder: string[];
}
export function Annotations({
annotations,
handleAnnotationChange,
removeAnnotation,
errors,
placeholder,
}: Props) {
return (
<>
{annotations.map((annotation, i) => (
<div className="row" key={annotation.ID}>
<div className="form-group !pl-0 col-sm-4 !m-0">
<div className="input-group input-group-sm">
<span className="input-group-addon required">Key</span>
<input
name={`annotation_key_${i}`}
type="text"
className="form-control form-control-sm"
placeholder={placeholder[0]}
defaultValue={annotation.Key}
onChange={(e: ChangeEvent<HTMLInputElement>) =>
handleAnnotationChange(i, 'Key', e.target.value)
}
/>
</div>
{errors[`annotations.key[${i}]`] && (
<FormError className="mt-1 !mb-0">
{errors[`annotations.key[${i}]`]}
</FormError>
)}
</div>
<div className="form-group !pl-0 col-sm-4 !m-0">
<div className="input-group input-group-sm">
<span className="input-group-addon required">Value</span>
<input
name={`annotation_value_${i}`}
type="text"
className="form-control form-control-sm"
placeholder={placeholder[1]}
defaultValue={annotation.Value}
onChange={(e: ChangeEvent<HTMLInputElement>) =>
handleAnnotationChange(i, 'Value', e.target.value)
}
/>
</div>
{errors[`annotations.value[${i}]`] && (
<FormError className="mt-1 !mb-0">
{errors[`annotations.value[${i}]`]}
</FormError>
)}
</div>
<div className="col-sm-3 !pl-0 !m-0">
<button
className="btn btn-sm btn-dangerlight btn-only-icon !ml-0"
type="button"
onClick={() => removeAnnotation(i)}
>
<Icon icon="trash-2" size="md" feather />
</button>
</div>
</div>
))}
</>
);
}

View File

@ -0,0 +1,5 @@
export interface Annotation {
Key: string;
Value: string;
ID: string;
}

View File

@ -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'),
}
);
}

View File

@ -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<Ingress[]>(
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<Ingress[]>(
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<IngressController[]>(
`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;
}

View File

@ -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;
}

View File

@ -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<string, string>;
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;
}

View File

@ -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');
},
}
);
}

View File

@ -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<Service[]>(
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;
}

View File

@ -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;
}

View File

@ -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 = {

View File

@ -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' },
};
}

View File

@ -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<HTMLAnchorElement>['target'];
}
export function Link({
title = '',
className,
children,
...props
}: PropsWithChildren<Props> & UISrefProps) {
return (
// eslint-disable-next-line react/jsx-props-no-spreading
<UISref {...props}>
<UISref className={clsx('no-decoration', className)} {...props}>
{/* eslint-disable-next-line jsx-a11y/anchor-is-valid */}
<a title={title}>{children}</a>
<a title={title} target={props.target}>
{children}
</a>
</UISref>
);
}

View File

@ -141,7 +141,11 @@ export function Datatable<
<TableSettingsProvider settings={settingsStore}>
<Table.Container>
{isTitleVisible(titleOptions) && (
<Table.Title label={titleOptions.title} icon={titleOptions.icon}>
<Table.Title
label={titleOptions.title}
icon={titleOptions.icon}
featherIcon={titleOptions.featherIcon}
>
<SearchBar value={searchBarValue} onChange={setGlobalFilter} />
{renderTableActions && (
<Table.Actions>

View File

@ -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<string>();
preFilteredRows.forEach((row) => {
options.add(row.values[id]);
});
return Array.from(options);
}, [id, preFilteredRows]);
return (
<MultipleSelectionFilter
options={options}
filterKey={id}
value={filterValue}
onChange={setFilter}
/>
);
}
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({
</MenuButton>
<MenuPopover className="dropdown-menu">
<div className="tableMenu">
<div className="menuHeader">Filter by state</div>
<div className="menuHeader">{menuTitle}</div>
<div className="menuContent">
{options.map((option, index) => (
<div className="md-checkbox" key={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<string>();
preFilteredRows.forEach((row) => {
options.add(row.values[id]);
});
return Array.from(options);
}, [id, preFilteredRows]);
return (
<MultipleSelectionFilter
options={options}
filterKey={id}
value={filterValue}
onChange={setFilter}
menuTitle={menuTitle}
/>
);
};
}

View File

@ -1,16 +1,17 @@
import { PropsWithChildren } from 'react';
import clsx from 'clsx';
import { Icon } from '@@/Icon';
export function FormError({ children }: PropsWithChildren<unknown>) {
interface Props {
className?: string;
}
export function FormError({ children, className }: PropsWithChildren<Props>) {
return (
<div className="small text-warning vertical-center">
<Icon
icon="alert-triangle"
feather
className="icon icon-sm icon-warning"
/>
{children}
</div>
<p className={clsx(`text-muted small vertical-center`, className)}>
<Icon icon="alert-triangle" className="icon-warning" feather />
<span className="text-warning">{children}</span>
</p>
);
}

View File

@ -57,7 +57,7 @@ export function ContainersDatatable({
<RowProvider context={{ environment }}>
<Datatable
titleOptions={{
icon: 'fa-cubes',
icon: 'svg-cubes',
title: 'Containers',
}}
settingsStore={settings}

View File

@ -0,0 +1,29 @@
import { useQuery } from 'react-query';
import { EnvironmentId } from '@/portainer/environments/types';
import { error as notifyError } from '@/portainer/services/notifications';
import { getConfigMaps } from './service';
export function useConfigurations(
environmentId: EnvironmentId,
namespace?: string
) {
return useQuery(
[
'environments',
environmentId,
'kubernetes',
'namespaces',
namespace,
'configurations',
],
() => (namespace ? getConfigMaps(environmentId, namespace) : []),
{
onError: (err) => {
notifyError('Failure', err as Error, 'Unable to get configurations');
},
enabled: !!namespace,
}
);
}

View File

@ -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<Configuration[]>(
`kubernetes/${environmentId}/namespaces/${namespace}/configmaps`
);
return configmaps;
} catch (e) {
throw parseAxiosError(e as Error, 'Unable to retrieve configmaps');
}
}

View File

@ -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;
}

View File

@ -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.');
},
}
);
}

View File

@ -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<Namespaces>(
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<Namespaces>(
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;
}

View File

@ -0,0 +1,6 @@
export interface Namespaces {
[key: string]: {
IsDefault: boolean;
IsSystem: boolean;
};
}

View File

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

View File

@ -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"
/>
<SidebarItem
to="kubernetes.ingresses"
params={{ endpointId: environmentId }}
label="Ingresses"
data-cy="k8sSidebar-ingresses"
icon={Route}
/>
<SidebarItem
to="kubernetes.configurations"
params={{ endpointId: environmentId }}
@ -97,7 +106,7 @@ export function KubernetesSidebar({ environmentId }: Props) {
>
<SidebarItem
to="kubernetes.cluster.setup"
params={{ id: environmentId }}
params={{ endpointId: environmentId }}
label="Setup"
data-cy="k8sSidebar-setup"
/>
@ -110,7 +119,7 @@ export function KubernetesSidebar({ environmentId }: Props) {
>
<SidebarItem
to="kubernetes.cluster.securityConstraint"
params={{ id: environmentId }}
params={{ endpointId: environmentId }}
label="Security constraints"
data-cy="k8sSidebar-securityConstraints"
/>

50
configmaps_and_secrets.go Normal file
View File

@ -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)
}

550
ingresses.go Normal file
View File

@ -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
}

162
namespaces.go Normal file
View File

@ -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
}

188
services.go Normal file
View File

@ -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
}