diff --git a/api/http/handler/kubernetes/client.go b/api/http/handler/kubernetes/client.go index a85f2cff9..6612ab7f4 100644 --- a/api/http/handler/kubernetes/client.go +++ b/api/http/handler/kubernetes/client.go @@ -30,8 +30,8 @@ func (handler *Handler) prepareKubeClient(r *http.Request) (*cli.KubeClient, *ht log.Error().Err(err).Str("context", "prepareKubeClient").Msg("Unable to get a privileged Kubernetes client for the user.") return nil, httperror.InternalServerError("Unable to get a privileged Kubernetes client for the user.", err) } - pcli.IsKubeAdmin = cli.IsKubeAdmin - pcli.NonAdminNamespaces = cli.NonAdminNamespaces + pcli.SetIsKubeAdmin(cli.GetIsKubeAdmin()) + pcli.SetClientNonAdminNamespaces(cli.GetClientNonAdminNamespaces()) return pcli, nil } diff --git a/api/http/handler/kubernetes/cluster_role_bindings.go b/api/http/handler/kubernetes/cluster_role_bindings.go index a5050c947..83621a900 100644 --- a/api/http/handler/kubernetes/cluster_role_bindings.go +++ b/api/http/handler/kubernetes/cluster_role_bindings.go @@ -32,7 +32,7 @@ func (handler *Handler) getAllKubernetesClusterRoleBindings(w http.ResponseWrite return httperror.Forbidden("User is not authorized to fetch cluster role bindings from the Kubernetes cluster.", httpErr) } - if !cli.IsKubeAdmin { + if !cli.GetIsKubeAdmin() { log.Error().Str("context", "getAllKubernetesClusterRoleBindings").Msg("user is not authorized to fetch cluster role bindings from the Kubernetes cluster.") return httperror.Forbidden("User is not authorized to fetch cluster role bindings from the Kubernetes cluster.", nil) } diff --git a/api/http/handler/kubernetes/cluster_roles.go b/api/http/handler/kubernetes/cluster_roles.go index 3fd2ca8aa..6d5d028be 100644 --- a/api/http/handler/kubernetes/cluster_roles.go +++ b/api/http/handler/kubernetes/cluster_roles.go @@ -32,7 +32,7 @@ func (handler *Handler) getAllKubernetesClusterRoles(w http.ResponseWriter, r *h return httperror.Forbidden("User is not authorized to fetch cluster roles from the Kubernetes cluster.", httpErr) } - if !cli.IsKubeAdmin { + if !cli.GetIsKubeAdmin() { log.Error().Str("context", "getAllKubernetesClusterRoles").Msg("user is not authorized to fetch cluster roles from the Kubernetes cluster.") return httperror.Forbidden("User is not authorized to fetch cluster roles from the Kubernetes cluster.", nil) } diff --git a/api/http/handler/kubernetes/event.go b/api/http/handler/kubernetes/event.go new file mode 100644 index 000000000..0e226d5ec --- /dev/null +++ b/api/http/handler/kubernetes/event.go @@ -0,0 +1,102 @@ +package kubernetes + +import ( + "net/http" + + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" + "github.com/rs/zerolog/log" + k8serrors "k8s.io/apimachinery/pkg/api/errors" +) + +// @id getKubernetesEventsForNamespace +// @summary Gets kubernetes events for namespace +// @description Get events by optional query param resourceId for a given namespace. +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @produce json +// @param id path int true "Environment identifier" +// @param namespace path string true "The namespace name the events are associated to" +// @param resourceId query string false "The resource id of the involved kubernetes object" example:"e5b021b6-4bce-4c06-bd3b-6cca906797aa" +// @success 200 {object} models.Event[] "Success" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." +// @failure 500 "Server error occurred while attempting to retrieve the events within the specified namespace." +// @router /kubernetes/{id}/namespaces/{namespace}/events [get] +func (handler *Handler) getKubernetesEventsForNamespace(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + namespace, err := request.RetrieveRouteVariableValue(r, "namespace") + if err != nil { + log.Error().Err(err).Str("context", "getKubernetesEvents").Str("namespace", namespace).Msg("Unable to retrieve namespace identifier route variable") + return httperror.BadRequest("Unable to retrieve namespace identifier route variable", err) + } + + resourceId, err := request.RetrieveQueryParameter(r, "resourceId", true) + if err != nil { + log.Error().Err(err).Str("context", "getKubernetesEvents").Msg("Unable to retrieve resourceId query parameter") + return httperror.BadRequest("Unable to retrieve resourceId query parameter", err) + } + + cli, httpErr := handler.getProxyKubeClient(r) + if httpErr != nil { + log.Error().Err(httpErr).Str("context", "getKubernetesEvents").Str("resourceId", resourceId).Msg("Unable to get a Kubernetes client for the user") + return httperror.InternalServerError("Unable to get a Kubernetes client for the user", httpErr) + } + + events, err := cli.GetEvents(namespace, resourceId) + if err != nil { + if k8serrors.IsUnauthorized(err) || k8serrors.IsForbidden(err) { + log.Error().Err(err).Str("context", "getKubernetesEvents").Msg("Unauthorized access to the Kubernetes API") + return httperror.Forbidden("Unauthorized access to the Kubernetes API", err) + } + + log.Error().Err(err).Str("context", "getKubernetesEvents").Msg("Unable to retrieve events") + return httperror.InternalServerError("Unable to retrieve events", err) + } + + return response.JSON(w, events) +} + +// @id getAllKubernetesEvents +// @summary Gets kubernetes events +// @description Get events by query param resourceId +// @description **Access policy**: Authenticated user. +// @tags kubernetes +// @security ApiKeyAuth || jwt +// @produce json +// @param id path int true "Environment identifier" +// @param resourceId query string false "The resource id of the involved kubernetes object" example:"e5b021b6-4bce-4c06-bd3b-6cca906797aa" +// @success 200 {object} models.Event[] "Success" +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." +// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." +// @failure 500 "Server error occurred while attempting to retrieve the events." +// @router /kubernetes/{id}/events [get] +func (handler *Handler) getAllKubernetesEvents(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + resourceId, err := request.RetrieveQueryParameter(r, "resourceId", true) + if err != nil { + log.Error().Err(err).Str("context", "getKubernetesEvents").Msg("Unable to retrieve resourceId query parameter") + return httperror.BadRequest("Unable to retrieve resourceId query parameter", err) + } + + cli, httpErr := handler.getProxyKubeClient(r) + if httpErr != nil { + log.Error().Err(httpErr).Str("context", "getKubernetesEvents").Str("resourceId", resourceId).Msg("Unable to get a Kubernetes client for the user") + return httperror.InternalServerError("Unable to get a Kubernetes client for the user", httpErr) + } + + events, err := cli.GetEvents("", resourceId) + if err != nil { + if k8serrors.IsUnauthorized(err) || k8serrors.IsForbidden(err) { + log.Error().Err(err).Str("context", "getKubernetesEvents").Msg("Unauthorized access to the Kubernetes API") + return httperror.Forbidden("Unauthorized access to the Kubernetes API", err) + } + + log.Error().Err(err).Str("context", "getKubernetesEvents").Msg("Unable to retrieve events") + return httperror.InternalServerError("Unable to retrieve events", err) + } + + return response.JSON(w, events) +} diff --git a/api/http/handler/kubernetes/event_test.go b/api/http/handler/kubernetes/event_test.go new file mode 100644 index 000000000..77f38c511 --- /dev/null +++ b/api/http/handler/kubernetes/event_test.go @@ -0,0 +1,60 @@ +package kubernetes + +import ( + "net/http" + "net/http/httptest" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/datastore" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/authorization" + "github.com/portainer/portainer/api/internal/testhelpers" + "github.com/portainer/portainer/api/jwt" + "github.com/portainer/portainer/api/kubernetes" + kubeClient "github.com/portainer/portainer/api/kubernetes/cli" + "github.com/stretchr/testify/assert" +) + +// Currently this test just tests the HTTP Handler is setup correctly, in the future we should move the ClientFactory to a mock in order +// test the logic in event.go +func TestGetKubernetesEvents(t *testing.T) { + is := assert.New(t) + + _, store := datastore.MustNewTestStore(t, true, true) + + err := store.Endpoint().Create(&portainer.Endpoint{ + ID: 1, + Type: portainer.AgentOnKubernetesEnvironment, + }, + ) + is.NoError(err, "error creating environment") + + err = store.User().Create(&portainer.User{Username: "admin", Role: portainer.AdministratorRole}) + is.NoError(err, "error creating a user") + + jwtService, err := jwt.NewService("1h", store) + is.NoError(err, "Error initiating jwt service") + + tk, _, _ := jwtService.GenerateToken(&portainer.TokenData{ID: 1, Username: "admin", Role: portainer.AdministratorRole}) + + kubeClusterAccessService := kubernetes.NewKubeClusterAccessService("", "", "") + + cli := testhelpers.NewKubernetesClient() + factory, _ := kubeClient.NewClientFactory(nil, nil, store, "", "", "") + + authorizationService := authorization.NewService(store) + handler := NewHandler(testhelpers.NewTestRequestBouncer(), authorizationService, store, jwtService, kubeClusterAccessService, + factory, cli) + is.NotNil(handler, "Handler should not fail") + + req := httptest.NewRequest(http.MethodGet, "/kubernetes/1/events?resourceId=8", nil) + ctx := security.StoreTokenData(req, &portainer.TokenData{ID: 1, Username: "admin", Role: 1}) + req = req.WithContext(ctx) + testhelpers.AddTestSecurityCookie(req, tk) + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + is.Equal(http.StatusOK, rr.Code, "Status should be 200") +} diff --git a/api/http/handler/kubernetes/handler.go b/api/http/handler/kubernetes/handler.go index cc068e4a4..07f6bdf3f 100644 --- a/api/http/handler/kubernetes/handler.go +++ b/api/http/handler/kubernetes/handler.go @@ -58,6 +58,7 @@ func NewHandler(bouncer security.BouncerService, authorizationService *authoriza endpointRouter.Handle("/configmaps/count", httperror.LoggerHandler(h.getAllKubernetesConfigMapsCount)).Methods(http.MethodGet) endpointRouter.Handle("/cron_jobs", httperror.LoggerHandler(h.getAllKubernetesCronJobs)).Methods(http.MethodGet) endpointRouter.Handle("/cron_jobs/delete", httperror.LoggerHandler(h.deleteKubernetesCronJobs)).Methods(http.MethodPost) + endpointRouter.Handle("/events", httperror.LoggerHandler(h.getAllKubernetesEvents)).Methods(http.MethodGet) endpointRouter.Handle("/jobs", httperror.LoggerHandler(h.getAllKubernetesJobs)).Methods(http.MethodGet) endpointRouter.Handle("/jobs/delete", httperror.LoggerHandler(h.deleteKubernetesJobs)).Methods(http.MethodPost) endpointRouter.Handle("/cluster_roles", httperror.LoggerHandler(h.getAllKubernetesClusterRoles)).Methods(http.MethodGet) @@ -110,6 +111,7 @@ func NewHandler(bouncer security.BouncerService, authorizationService *authoriza // to keep it simple, we've decided to leave it like this. namespaceRouter := endpointRouter.PathPrefix("/namespaces/{namespace}").Subrouter() namespaceRouter.Handle("/configmaps/{configmap}", httperror.LoggerHandler(h.getKubernetesConfigMap)).Methods(http.MethodGet) + namespaceRouter.Handle("/events", httperror.LoggerHandler(h.getKubernetesEventsForNamespace)).Methods(http.MethodGet) 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) @@ -133,7 +135,7 @@ func NewHandler(bouncer security.BouncerService, authorizationService *authoriza // getProxyKubeClient gets a kubeclient for the user. It's generally what you want as it retrieves the kubeclient // from the Authorization token of the currently logged in user. The kubeclient that is not from the proxy is actually using // admin permissions. If you're unsure which one to use, use this. -func (h *Handler) getProxyKubeClient(r *http.Request) (*cli.KubeClient, *httperror.HandlerError) { +func (h *Handler) getProxyKubeClient(r *http.Request) (portainer.KubeClient, *httperror.HandlerError) { endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id") if err != nil { return nil, httperror.BadRequest(fmt.Sprintf("an error occurred during the getProxyKubeClient operation, the environment identifier route variable is invalid for /api/kubernetes/%d. Error: ", endpointID), err) @@ -253,7 +255,7 @@ func (handler *Handler) kubeClientMiddleware(next http.Handler) http.Handler { return } serverURL.Scheme = "https" - serverURL.Host = "localhost" + handler.KubernetesClientFactory.AddrHTTPS + serverURL.Host = "localhost" + handler.KubernetesClientFactory.GetAddrHTTPS() config.Clusters[0].Cluster.Server = serverURL.String() yaml, err := cli.GenerateYAML(config) diff --git a/api/http/models/kubernetes/event.go b/api/http/models/kubernetes/event.go new file mode 100644 index 000000000..be447b554 --- /dev/null +++ b/api/http/models/kubernetes/event.go @@ -0,0 +1,25 @@ +package kubernetes + +import "time" + +type K8sEvent struct { + Type string `json:"type"` + Name string `json:"name"` + Reason string `json:"reason"` + Message string `json:"message"` + Namespace string `json:"namespace"` + EventTime time.Time `json:"eventTime"` + Kind string `json:"kind,omitempty"` + Count int32 `json:"count"` + FirstTimestamp *time.Time `json:"firstTimestamp,omitempty"` + LastTimestamp *time.Time `json:"lastTimestamp,omitempty"` + UID string `json:"uid"` + InvolvedObjectKind K8sEventInvolvedObject `json:"involvedObject"` +} + +type K8sEventInvolvedObject struct { + Kind string `json:"kind,omitempty"` + UID string `json:"uid"` + Name string `json:"name"` + Namespace string `json:"namespace"` +} diff --git a/api/internal/testhelpers/kube_client.go b/api/internal/testhelpers/kube_client.go new file mode 100644 index 000000000..550e7ce92 --- /dev/null +++ b/api/internal/testhelpers/kube_client.go @@ -0,0 +1,19 @@ +package testhelpers + +import ( + portainer "github.com/portainer/portainer/api" + models "github.com/portainer/portainer/api/http/models/kubernetes" +) + +type testKubeClient struct { + portainer.KubeClient +} + +func NewKubernetesClient() portainer.KubeClient { + return &testKubeClient{} +} + +// Event +func (kcl *testKubeClient) GetEvents(namespace string, resourceId string) ([]models.K8sEvent, error) { + return nil, nil +} diff --git a/api/kubernetes/cli/access.go b/api/kubernetes/cli/access.go index 73f8d50af..6f254c296 100644 --- a/api/kubernetes/cli/access.go +++ b/api/kubernetes/cli/access.go @@ -143,3 +143,23 @@ func (kcl *KubeClient) GetNonAdminNamespaces(userID int, teamIDs []int, isRestri return nonAdminNamespaces, nil } + +// GetIsKubeAdmin retrieves true if client is admin +func (client *KubeClient) GetIsKubeAdmin() bool { + return client.IsKubeAdmin +} + +// UpdateIsKubeAdmin sets whether the kube client is admin +func (client *KubeClient) SetIsKubeAdmin(isKubeAdmin bool) { + client.IsKubeAdmin = isKubeAdmin +} + +// GetClientNonAdminNamespaces retrieves non-admin namespaces +func (client *KubeClient) GetClientNonAdminNamespaces() []string { + return client.NonAdminNamespaces +} + +// UpdateClientNonAdminNamespaces sets the client non admin namespace list +func (client *KubeClient) SetClientNonAdminNamespaces(nonAdminNamespaces []string) { + client.NonAdminNamespaces = nonAdminNamespaces +} diff --git a/api/kubernetes/cli/client.go b/api/kubernetes/cli/client.go index 6d2cc437c..a40a865f1 100644 --- a/api/kubernetes/cli/client.go +++ b/api/kubernetes/cli/client.go @@ -82,6 +82,10 @@ func (factory *ClientFactory) RemoveKubeClient(endpointID portainer.EndpointID) factory.endpointProxyClients.Delete(strconv.Itoa(int(endpointID))) } +func (factory *ClientFactory) GetAddrHTTPS() string { + return factory.AddrHTTPS +} + // GetPrivilegedKubeClient checks if an existing client is already registered for the environment(endpoint) and returns it if one is found. // If no client is registered, it will create a new client, register it, and returns it. func (factory *ClientFactory) GetPrivilegedKubeClient(endpoint *portainer.Endpoint) (*KubeClient, error) { diff --git a/api/kubernetes/cli/event.go b/api/kubernetes/cli/event.go new file mode 100644 index 000000000..03472fca6 --- /dev/null +++ b/api/kubernetes/cli/event.go @@ -0,0 +1,93 @@ +package cli + +import ( + "context" + + models "github.com/portainer/portainer/api/http/models/kubernetes" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// GetEvents gets all the Events for a given namespace and resource +// If the user is a kube admin, it returns all events in the namespace +// Otherwise, it returns only the events in the non-admin namespaces +func (kcl *KubeClient) GetEvents(namespace string, resourceId string) ([]models.K8sEvent, error) { + if kcl.IsKubeAdmin { + return kcl.fetchAllEvents(namespace, resourceId) + } + + return kcl.fetchEventsForNonAdmin(namespace, resourceId) +} + +// fetchEventsForNonAdmin returns all events in the given namespace and resource +// It returns only the events in the non-admin namespaces +func (kcl *KubeClient) fetchEventsForNonAdmin(namespace string, resourceId string) ([]models.K8sEvent, error) { + if len(kcl.NonAdminNamespaces) == 0 { + return nil, nil + } + + events, err := kcl.fetchAllEvents(namespace, resourceId) + if err != nil { + return nil, err + } + + nonAdminNamespaceSet := kcl.buildNonAdminNamespacesMap() + results := make([]models.K8sEvent, 0) + for _, event := range events { + if _, ok := nonAdminNamespaceSet[event.Namespace]; ok { + results = append(results, event) + } + } + + return results, nil +} + +// fetchEventsForNonAdmin returns all events in the given namespace and resource +// It returns all events in the namespace and resource +func (kcl *KubeClient) fetchAllEvents(namespace string, resourceId string) ([]models.K8sEvent, error) { + options := metav1.ListOptions{} + if resourceId != "" { + options.FieldSelector = "involvedObject.uid=" + resourceId + } + + list, err := kcl.cli.CoreV1().Events(namespace).List(context.TODO(), options) + if err != nil { + return nil, err + } + + results := make([]models.K8sEvent, 0) + for _, event := range list.Items { + results = append(results, parseEvent(&event)) + } + + return results, nil +} + +func parseEvent(event *corev1.Event) models.K8sEvent { + result := models.K8sEvent{ + Type: event.Type, + Name: event.Name, + Message: event.Message, + Reason: event.Reason, + Namespace: event.Namespace, + EventTime: event.EventTime.UTC(), + Kind: event.Kind, + Count: event.Count, + UID: string(event.ObjectMeta.GetUID()), + InvolvedObjectKind: models.K8sEventInvolvedObject{ + Kind: event.InvolvedObject.Kind, + UID: string(event.InvolvedObject.UID), + Name: event.InvolvedObject.Name, + Namespace: event.InvolvedObject.Namespace, + }, + } + + if !event.LastTimestamp.Time.IsZero() { + result.LastTimestamp = &event.LastTimestamp.Time + } + if !event.FirstTimestamp.Time.IsZero() { + result.FirstTimestamp = &event.FirstTimestamp.Time + } + + return result +} diff --git a/api/kubernetes/cli/event_test.go b/api/kubernetes/cli/event_test.go new file mode 100644 index 000000000..926928317 --- /dev/null +++ b/api/kubernetes/cli/event_test.go @@ -0,0 +1,108 @@ +package cli + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + kfake "k8s.io/client-go/kubernetes/fake" +) + +// TestGetEvents tests the GetEvents method +// It creates a fake Kubernetes client and passes it to the GetEvents method +// It then logs the fetched events and validated the data returned +func TestGetEvents(t *testing.T) { + t.Run("can get events for resource id when admin", func(t *testing.T) { + kcl := &KubeClient{ + cli: kfake.NewSimpleClientset(), + instanceID: "instance", + IsKubeAdmin: true, + } + event := corev1.Event{ + InvolvedObject: corev1.ObjectReference{UID: "resourceId"}, + Action: "something", + ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: "myEvent"}, + EventTime: metav1.NowMicro(), + Type: "warning", + Message: "This event has a very serious warning", + } + _, err := kcl.cli.CoreV1().Events("default").Create(context.TODO(), &event, metav1.CreateOptions{}) + if err != nil { + t.Fatalf("Failed to create Event: %v", err) + } + + events, err := kcl.GetEvents("default", "resourceId") + + if err != nil { + t.Fatalf("Failed to fetch Cron Jobs: %v", err) + } + t.Logf("Fetched Events: %v", events) + require.Equal(t, 1, len(events), "Expected to return 1 event") + assert.Equal(t, event.Message, events[0].Message, "Expected Message to be equal to event message created") + assert.Equal(t, event.Type, events[0].Type, "Expected Type to be equal to event type created") + assert.Equal(t, event.EventTime.UTC(), events[0].EventTime, "Expected EventTime to be saved as a string from event time created") + }) + t.Run("can get kubernetes events for non admin namespace when non admin", func(t *testing.T) { + kcl := &KubeClient{ + cli: kfake.NewSimpleClientset(), + instanceID: "instance", + IsKubeAdmin: false, + NonAdminNamespaces: []string{"nonAdmin"}, + } + event := corev1.Event{ + InvolvedObject: corev1.ObjectReference{UID: "resourceId"}, + Action: "something", + ObjectMeta: metav1.ObjectMeta{Namespace: "nonAdmin", Name: "myEvent"}, + EventTime: metav1.NowMicro(), + Type: "warning", + Message: "This event has a very serious warning", + } + _, err := kcl.cli.CoreV1().Events("nonAdmin").Create(context.TODO(), &event, metav1.CreateOptions{}) + if err != nil { + t.Fatalf("Failed to create Event: %v", err) + } + + events, err := kcl.GetEvents("nonAdmin", "resourceId") + + if err != nil { + t.Fatalf("Failed to fetch Cron Jobs: %v", err) + } + t.Logf("Fetched Events: %v", events) + require.Equal(t, 1, len(events), "Expected to return 1 event") + assert.Equal(t, event.Message, events[0].Message, "Expected Message to be equal to event message created") + assert.Equal(t, event.Type, events[0].Type, "Expected Type to be equal to event type created") + assert.Equal(t, event.EventTime.UTC(), events[0].EventTime, "Expected EventTime to be saved as a string from event time created") + }) + + t.Run("cannot get kubernetes events for admin namespace when non admin", func(t *testing.T) { + kcl := &KubeClient{ + cli: kfake.NewSimpleClientset(), + instanceID: "instance", + IsKubeAdmin: false, + NonAdminNamespaces: []string{"nonAdmin"}, + } + event := corev1.Event{ + InvolvedObject: corev1.ObjectReference{UID: "resourceId"}, + Action: "something", + ObjectMeta: metav1.ObjectMeta{Namespace: "admin", Name: "myEvent"}, + EventTime: metav1.NowMicro(), + Type: "warning", + Message: "This event has a very serious warning", + } + _, err := kcl.cli.CoreV1().Events("admin").Create(context.TODO(), &event, metav1.CreateOptions{}) + if err != nil { + t.Fatalf("Failed to create Event: %v", err) + } + + events, err := kcl.GetEvents("admin", "resourceId") + + if err != nil { + t.Fatalf("Failed to fetch Cron Jobs: %v", err) + } + t.Logf("Fetched Events: %v", events) + assert.Equal(t, 0, len(events), "Expected to return 0 events") + }) +} diff --git a/api/portainer.go b/api/portainer.go index f61bb1af2..f63d04ddb 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "io" + "net/http" "time" "github.com/docker/docker/api/types" @@ -13,6 +14,7 @@ import ( gittypes "github.com/portainer/portainer/api/git/types" models "github.com/portainer/portainer/api/http/models/kubernetes" "github.com/portainer/portainer/pkg/featureflags" + httperror "github.com/portainer/portainer/pkg/libhttp/error" "github.com/segmentio/encoding/json" "golang.org/x/oauth2" @@ -1531,56 +1533,127 @@ type ( // KubeClient represents a service used to query a Kubernetes environment(endpoint) KubeClient interface { - ServerVersion() (*version.Info, error) + // Access + GetIsKubeAdmin() bool + SetIsKubeAdmin(isKubeAdmin bool) + GetClientNonAdminNamespaces() []string + SetClientNonAdminNamespaces([]string) + NamespaceAccessPoliciesDeleteNamespace(ns string) error + UpdateNamespaceAccessPolicies(accessPolicies map[string]K8sNamespaceAccessPolicy) error + GetNamespaceAccessPolicies() (map[string]K8sNamespaceAccessPolicy, error) + GetNonAdminNamespaces(userID int, teamIDs []int, isRestrictDefaultNamespace bool) ([]string, error) - SetupUserServiceAccount(userID int, teamIDs []int, restrictDefaultNamespace bool) error - IsRBACEnabled() (bool, error) - GetPortainerUserServiceAccount(tokendata *TokenData) (*corev1.ServiceAccount, error) - GetServiceAccounts(namespace string) ([]models.K8sServiceAccount, error) - DeleteServiceAccounts(reqs models.K8sServiceAccountDeleteRequests) error - GetServiceAccountBearerToken(userID int) (string, error) - CreateUserShellPod(ctx context.Context, serviceAccountName, shellPodImage string) (*KubernetesShellPod, error) + // Applications + GetApplications(namespace, nodeName string) ([]models.K8sApplication, error) + GetApplicationsResource(namespace, node string) (models.K8sApplicationResource, error) + + // ClusterRole + GetClusterRoles() ([]models.K8sClusterRole, error) + DeleteClusterRoles(req models.K8sClusterRoleDeleteRequests) error + + // ConfigMap + GetConfigMap(namespace, configMapName string) (models.K8sConfigMap, error) + CombineConfigMapWithApplications(configMap models.K8sConfigMap) (models.K8sConfigMap, error) + + // CronJob + GetCronJobs(namespace string) ([]models.K8sCronJob, error) + DeleteCronJobs(payload models.K8sCronJobDeleteRequests) error + + // Event + GetEvents(namespace string, resourceId string) ([]models.K8sEvent, error) + + // Exec StartExecProcess(token string, useAdminToken bool, namespace, podName, containerName string, command []string, stdin io.Reader, stdout io.Writer, errChan chan error) + // ClusterRoleBinding + GetClusterRoleBindings() ([]models.K8sClusterRoleBinding, error) + DeleteClusterRoleBindings(reqs models.K8sClusterRoleBindingDeleteRequests) error + + // Dashboard + GetDashboard() (models.K8sDashboard, error) + + // Deployment HasStackName(namespace string, stackName string) (bool, error) - NamespaceAccessPoliciesDeleteNamespace(namespace string) error - CreateNamespace(info models.K8sNamespaceDetails) (*corev1.Namespace, error) - UpdateNamespace(info models.K8sNamespaceDetails) (*corev1.Namespace, error) - GetNamespaces() (map[string]K8sNamespaceInfo, error) - GetNamespace(string) (K8sNamespaceInfo, error) - DeleteNamespace(namespace string) (*corev1.Namespace, error) - GetConfigMaps(namespace string) ([]models.K8sConfigMap, error) - GetSecrets(namespace string) ([]models.K8sSecret, error) + + // Ingress GetIngressControllers() (models.K8sIngressControllers, error) - GetApplications(namespace, nodename string) ([]models.K8sApplication, error) - GetMetrics() (models.K8sMetrics, error) - GetStorage() ([]KubernetesStorageClassConfig, error) - CreateIngress(namespace string, info models.K8sIngressInfo, owner string) error - UpdateIngress(namespace string, info models.K8sIngressInfo) error + GetIngress(namespace, ingressName string) (models.K8sIngressInfo, error) GetIngresses(namespace string) ([]models.K8sIngressInfo, error) + CreateIngress(namespace string, info models.K8sIngressInfo, owner string) 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 + UpdateIngress(namespace string, info models.K8sIngressInfo) error + CombineIngressWithService(ingress models.K8sIngressInfo) (models.K8sIngressInfo, error) + CombineIngressesWithServices(ingresses []models.K8sIngressInfo) ([]models.K8sIngressInfo, error) + + // Job + GetJobs(namespace string, includeCronJobChildren bool) ([]models.K8sJob, error) + DeleteJobs(payload models.K8sJobDeleteRequests) error + + // Metrics + GetMetrics() (models.K8sMetrics, error) + + // Namespace + ToggleSystemState(namespaceName string, isSystem bool) error + UpdateNamespace(info models.K8sNamespaceDetails) (*corev1.Namespace, error) + GetNamespace(name string) (K8sNamespaceInfo, error) + CreateNamespace(info models.K8sNamespaceDetails) (*corev1.Namespace, error) + GetNamespaces() (map[string]K8sNamespaceInfo, error) + CombineNamespaceWithResourceQuota(namespace K8sNamespaceInfo, w http.ResponseWriter) *httperror.HandlerError + DeleteNamespace(namespaceName string) (*corev1.Namespace, error) + CombineNamespacesWithResourceQuotas(namespaces map[string]K8sNamespaceInfo, w http.ResponseWriter) *httperror.HandlerError + ConvertNamespaceMapToSlice(namespaces map[string]K8sNamespaceInfo) []K8sNamespaceInfo + + // NodeLimits GetNodesLimits() (K8sNodesLimits, error) - GetMaxResourceLimits(name string, overCommitEnabled bool, resourceOverCommitPercent int) (K8sNodeLimits, error) - GetNamespaceAccessPolicies() (map[string]K8sNamespaceAccessPolicy, error) - UpdateNamespaceAccessPolicies(accessPolicies map[string]K8sNamespaceAccessPolicy) error + GetMaxResourceLimits(skipNamespace string, overCommitEnabled bool, resourceOverCommitPercent int) (K8sNodeLimits, error) + + // Pod + CreateUserShellPod(ctx context.Context, serviceAccountName, shellPodImage string) (*KubernetesShellPod, error) + + // RBAC + IsRBACEnabled() (bool, error) + + // Registries DeleteRegistrySecret(registry RegistryID, namespace string) error CreateRegistrySecret(registry *Registry, namespace string) error IsRegistrySecret(namespace, secretName string) (bool, error) - ToggleSystemState(namespace string, isSystem bool) error - GetClusterRoles() ([]models.K8sClusterRole, error) - DeleteClusterRoles(models.K8sClusterRoleDeleteRequests) error - GetClusterRoleBindings() ([]models.K8sClusterRoleBinding, error) - DeleteClusterRoleBindings(models.K8sClusterRoleBindingDeleteRequests) error - - GetRoles(namespace string) ([]models.K8sRole, error) - DeleteRoles(models.K8sRoleDeleteRequests) error + // RoleBinding GetRoleBindings(namespace string) ([]models.K8sRoleBinding, error) - DeleteRoleBindings(models.K8sRoleBindingDeleteRequests) error + DeleteRoleBindings(reqs models.K8sRoleBindingDeleteRequests) error + + // Role + DeleteRoles(reqs models.K8sRoleDeleteRequests) error + + // Secret + GetSecrets(namespace string) ([]models.K8sSecret, error) + GetSecret(namespace string, secretName string) (models.K8sSecret, error) + CombineSecretWithApplications(secret models.K8sSecret) (models.K8sSecret, error) + + // ServiceAccount + GetServiceAccounts(namespace string) ([]models.K8sServiceAccount, error) + DeleteServiceAccounts(reqs models.K8sServiceAccountDeleteRequests) error + SetupUserServiceAccount(int, []int, bool) error + GetPortainerUserServiceAccount(tokendata *TokenData) (*corev1.ServiceAccount, error) + GetServiceAccountBearerToken(userID int) (string, error) + + // Service + GetServices(namespace string) ([]models.K8sServiceInfo, error) + CombineServicesWithApplications(services []models.K8sServiceInfo) ([]models.K8sServiceInfo, error) + CreateService(namespace string, info models.K8sServiceInfo) error + DeleteServices(reqs models.K8sServiceDeleteRequests) error + UpdateService(namespace string, info models.K8sServiceInfo) error + + // ServerVersion + ServerVersion() (*version.Info, error) + + // Storage + GetStorage() ([]KubernetesStorageClassConfig, error) + + // Volumes + GetVolumes(namespace string) ([]models.K8sVolumeInfo, error) + GetVolume(namespace, volumeName string) (*models.K8sVolumeInfo, error) + CombineVolumesWithApplications(volumes *[]models.K8sVolumeInfo) (*[]models.K8sVolumeInfo, error) } // KubernetesDeployer represents a service to deploy a manifest inside a Kubernetes environment(endpoint) diff --git a/app/kubernetes/views/configurations/configmap/edit/configMap.html b/app/kubernetes/views/configurations/configmap/edit/configMap.html index 89e1c38c5..70a2d4f1a 100644 --- a/app/kubernetes/views/configurations/configmap/edit/configMap.html +++ b/app/kubernetes/views/configurations/configmap/edit/configMap.html @@ -58,7 +58,7 @@ diff --git a/app/kubernetes/views/configurations/secret/edit/secret.html b/app/kubernetes/views/configurations/secret/edit/secret.html index 0309d356c..2e939c87e 100644 --- a/app/kubernetes/views/configurations/secret/edit/secret.html +++ b/app/kubernetes/views/configurations/secret/edit/secret.html @@ -65,7 +65,7 @@ diff --git a/app/react/kubernetes/components/EventsDatatable/EventsDatatable.test.tsx b/app/react/kubernetes/components/EventsDatatable/EventsDatatable.test.tsx new file mode 100644 index 000000000..c5dc49c16 --- /dev/null +++ b/app/react/kubernetes/components/EventsDatatable/EventsDatatable.test.tsx @@ -0,0 +1,83 @@ +import { render, screen } from '@testing-library/react'; + +import { withTestQueryProvider } from '@/react/test-utils/withTestQuery'; +import { withTestRouter } from '@/react/test-utils/withRouter'; +import { UserViewModel } from '@/portainer/models/user'; +import { withUserProvider } from '@/react/test-utils/withUserProvider'; +import { TableSettings } from '@/react/kubernetes/datatables/DefaultDatatableSettings'; + +import { TableState } from '@@/datatables/useTableState'; + +import { Event } from '../../queries/types'; + +import { EventsDatatable } from './EventsDatatable'; + +// Mock the necessary hooks and dependencies +const mockTableState: TableState = { + sortBy: { id: 'Date', desc: true }, + pageSize: 10, + search: '', + autoRefreshRate: 0, + showSystemResources: false, + setSortBy: vi.fn(), + setPageSize: vi.fn(), + setSearch: vi.fn(), + setAutoRefreshRate: vi.fn(), + setShowSystemResources: vi.fn(), +}; + +vi.mock('../../datatables/default-kube-datatable-store', () => ({ + useKubeStore: () => mockTableState, +})); + +function renderComponent() { + const user = new UserViewModel({ Username: 'user' }); + + const events: Event[] = [ + { + type: 'Warning', + name: 'name', + message: 'not sure if this what you want to do', + namespace: 'default', + reason: 'unknown', + count: 1, + eventTime: new Date('2025-01-02T15:04:05Z'), + uid: '4500fc9c-0cc8-4695-b4c4-989ac021d1d6', + involvedObject: { + kind: 'configMap', + uid: '35', + name: 'name', + namespace: 'default', + }, + }, + ]; + + const Wrapped = withTestQueryProvider( + withUserProvider( + withTestRouter(() => ( + + )), + user + ) + ); + return { ...render(), events }; +} + +describe('EventsDatatable', () => { + it('should display events when data is loaded', async () => { + const { events } = renderComponent(); + const event = events[0]; + + expect(screen.getByText(event.message || '')).toBeInTheDocument(); + expect(screen.getAllByText(event.type || '')).toHaveLength(2); + expect(screen.getAllByText(event.involvedObject.kind || '')).toHaveLength( + 2 + ); + }); +}); diff --git a/app/react/kubernetes/components/EventsDatatable/EventsDatatable.tsx b/app/react/kubernetes/components/EventsDatatable/EventsDatatable.tsx index ffb92e7bd..fd063535a 100644 --- a/app/react/kubernetes/components/EventsDatatable/EventsDatatable.tsx +++ b/app/react/kubernetes/components/EventsDatatable/EventsDatatable.tsx @@ -1,7 +1,7 @@ -import { Event } from 'kubernetes-types/core/v1'; import { History } from 'lucide-react'; import { ReactNode } from 'react'; +import { Event } from '@/react/kubernetes/queries/types'; import { IndexOptional } from '@/react/kubernetes/configs/types'; import { TableSettings } from '@/react/kubernetes/datatables/DefaultDatatableSettings'; @@ -38,7 +38,7 @@ export function EventsDatatable({ isLoading={isLoading} title={title} titleIcon={titleIcon} - getRowId={(row) => row.metadata?.uid || ''} + getRowId={(row) => row.uid || ''} disableSelect renderTableSettings={() => ( diff --git a/app/react/kubernetes/components/EventsDatatable/ResourceEventsDatatable.tsx b/app/react/kubernetes/components/EventsDatatable/ResourceEventsDatatable.tsx index f763043c9..bf04d00d9 100644 --- a/app/react/kubernetes/components/EventsDatatable/ResourceEventsDatatable.tsx +++ b/app/react/kubernetes/components/EventsDatatable/ResourceEventsDatatable.tsx @@ -29,9 +29,7 @@ export function ResourceEventsDatatable({ params: { endpointId }, } = useCurrentStateAndParams(); - const params = resourceId - ? { fieldSelector: `involvedObject.uid=${resourceId}` } - : {}; + const params = resourceId ? { resourceId: `${resourceId}` } : {}; const resourceEventsQuery = useEvents(endpointId, { namespace, params, diff --git a/app/react/kubernetes/components/EventsDatatable/columns/eventType.tsx b/app/react/kubernetes/components/EventsDatatable/columns/eventType.tsx index 0d381d682..d2f24e3ab 100644 --- a/app/react/kubernetes/components/EventsDatatable/columns/eventType.tsx +++ b/app/react/kubernetes/components/EventsDatatable/columns/eventType.tsx @@ -1,5 +1,6 @@ import { Row } from '@tanstack/react-table'; -import { Event } from 'kubernetes-types/core/v1'; + +import { Event } from '@/react/kubernetes/queries/types'; import { Badge, BadgeType } from '@@/Badge'; import { filterHOC } from '@@/datatables/Filter'; diff --git a/app/react/kubernetes/components/EventsDatatable/columns/helper.ts b/app/react/kubernetes/components/EventsDatatable/columns/helper.ts index 23a453238..dbb2a57d5 100644 --- a/app/react/kubernetes/components/EventsDatatable/columns/helper.ts +++ b/app/react/kubernetes/components/EventsDatatable/columns/helper.ts @@ -1,4 +1,5 @@ import { createColumnHelper } from '@tanstack/react-table'; -import { Event } from 'kubernetes-types/core/v1'; + +import { Event } from '@/react/kubernetes/queries/types'; export const columnHelper = createColumnHelper(); diff --git a/app/react/kubernetes/components/EventsDatatable/columns/kind.tsx b/app/react/kubernetes/components/EventsDatatable/columns/kind.tsx index 641662291..efc69fe4e 100644 --- a/app/react/kubernetes/components/EventsDatatable/columns/kind.tsx +++ b/app/react/kubernetes/components/EventsDatatable/columns/kind.tsx @@ -1,5 +1,6 @@ import { Row } from '@tanstack/react-table'; -import { Event } from 'kubernetes-types/core/v1'; + +import { Event } from '@/react/kubernetes/queries/types'; import { filterHOC } from '@@/datatables/Filter'; diff --git a/app/react/kubernetes/helm/HelmApplicationView/HelmApplicationView.test.tsx b/app/react/kubernetes/helm/HelmApplicationView/HelmApplicationView.test.tsx index 89f522d3a..9cad1d046 100644 --- a/app/react/kubernetes/helm/HelmApplicationView/HelmApplicationView.test.tsx +++ b/app/react/kubernetes/helm/HelmApplicationView/HelmApplicationView.test.tsx @@ -184,15 +184,8 @@ describe( http.get('/api/endpoints/3/kubernetes/helm/test-release/history', () => HttpResponse.json(helmReleaseHistory) ), - http.get( - '/api/endpoints/3/kubernetes/api/v1/namespaces/default/events', - () => - HttpResponse.json({ - kind: 'EventList', - apiVersion: 'v1', - metadata: { resourceVersion: '12345' }, - items: [], - }) + http.get('/api/kubernetes/3/namespaces/default/events', () => + HttpResponse.json([]) ) ); @@ -236,15 +229,8 @@ describe( HttpResponse.error() ), // Add mock for events endpoint - http.get( - '/api/endpoints/3/kubernetes/api/v1/namespaces/default/events', - () => - HttpResponse.json({ - kind: 'EventList', - apiVersion: 'v1', - metadata: { resourceVersion: '12345' }, - items: [], - }) + http.get('/api/kubernetes/3/namespaces/default/events', () => + HttpResponse.json([]) ) ); @@ -274,15 +260,8 @@ describe( http.get('/api/endpoints/3/kubernetes/helm/test-release/history', () => HttpResponse.json(helmReleaseHistory) ), - http.get( - '/api/endpoints/3/kubernetes/api/v1/namespaces/default/events', - () => - HttpResponse.json({ - kind: 'EventList', - apiVersion: 'v1', - metadata: { resourceVersion: '12345' }, - items: [], - }) + http.get('/api/kubernetes/3/namespaces/default/events', () => + HttpResponse.json([]) ) ); diff --git a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/HelmEventsDatatable.test.tsx b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/HelmEventsDatatable.test.tsx index 08c48488a..67ff7d10b 100644 --- a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/HelmEventsDatatable.test.tsx +++ b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/HelmEventsDatatable.test.tsx @@ -1,7 +1,7 @@ import { render, screen, waitFor } from '@testing-library/react'; import { HttpResponse } from 'msw'; -import { Event, EventList } from 'kubernetes-types/core/v1'; +import { Event } from '@/react/kubernetes/queries/types'; import { server, http } from '@/setup-tests/server'; import { withTestQueryProvider } from '@/react/test-utils/withTestQuery'; import { withTestRouter } from '@/react/test-utils/withRouter'; @@ -56,136 +56,84 @@ const testResources: GenericResource[] = [ }, ]; -const mockEventsResponse: EventList = { - kind: 'EventList', - apiVersion: 'v1', - metadata: { - resourceVersion: '12345', +const mockEventsResponse: Event[] = [ + { + name: 'test-deployment-123456', + namespace: 'default', + reason: 'CreatedLoadBalancer', + eventTime: new Date('2023-01-01T00:00:00Z'), + uid: 'event-uid-1', + involvedObject: { + kind: 'Deployment', + name: 'test-deployment', + uid: 'test-deployment-uid', + namespace: 'default', + }, + message: 'Scaled up replica set test-deployment-abc123 to 1', + firstTimestamp: new Date('2023-01-01T00:00:00Z'), + lastTimestamp: new Date('2023-01-01T00:00:00Z'), + count: 1, + type: 'Normal', }, - items: [ - { - metadata: { - name: 'test-deployment-123456', - namespace: 'default', - uid: 'event-uid-1', - resourceVersion: '1000', - creationTimestamp: '2023-01-01T00:00:00Z', - }, - involvedObject: { - kind: 'Deployment', - namespace: 'default', - name: 'test-deployment', - uid: 'test-deployment-uid', - apiVersion: 'apps/v1', - resourceVersion: '2000', - }, - reason: 'ScalingReplicaSet', - message: 'Scaled up replica set test-deployment-abc123 to 1', - source: { - component: 'deployment-controller', - }, - firstTimestamp: '2023-01-01T00:00:00Z', - lastTimestamp: '2023-01-01T00:00:00Z', - count: 1, - type: 'Normal', - reportingComponent: 'deployment-controller', - reportingInstance: '', + { + name: 'test-service-123456', + namespace: 'default', + uid: 'event-uid-2', + eventTime: new Date('2023-01-01T00:00:00Z'), + involvedObject: { + kind: 'Service', + namespace: 'default', + name: 'test-service', + uid: 'test-service-uid', }, - { - metadata: { - name: 'test-service-123456', - namespace: 'default', - uid: 'event-uid-2', - resourceVersion: '1001', - creationTimestamp: '2023-01-01T00:00:00Z', - }, - involvedObject: { - kind: 'Service', - namespace: 'default', - name: 'test-service', - uid: 'test-service-uid', - apiVersion: 'v1', - resourceVersion: '2001', - }, - reason: 'CreatedLoadBalancer', - message: 'Created load balancer', - source: { - component: 'service-controller', - }, - firstTimestamp: '2023-01-01T00:00:00Z', - lastTimestamp: '2023-01-01T00:00:00Z', - count: 1, - type: 'Normal', - reportingComponent: 'service-controller', - reportingInstance: '', - }, - ], -}; + reason: 'CreatedLoadBalancer', + message: 'Created load balancer', + firstTimestamp: new Date('2023-01-01T00:00:00Z'), + lastTimestamp: new Date('2023-01-01T00:00:00Z'), + count: 1, + type: 'Normal', + }, +]; -const mixedEventsResponse: EventList = { - kind: 'EventList', - apiVersion: 'v1', - metadata: { - resourceVersion: '12345', +const mixedEventsResponse: Event[] = [ + { + name: 'test-deployment-123456', + namespace: 'default', + uid: 'event-uid-1', + eventTime: new Date('2023-01-01T00:00:00Z'), + involvedObject: { + kind: 'Deployment', + namespace: 'default', + name: 'test-deployment', + uid: 'test-deployment-uid', // This matches a resource UID + }, + reason: 'ScalingReplicaSet', + message: 'Scaled up replica set test-deployment-abc123 to 1', + + firstTimestamp: new Date('2023-01-01T00:00:00Z'), + lastTimestamp: new Date('2023-01-01T00:00:00Z'), + count: 1, + type: 'Normal', }, - items: [ - { - metadata: { - name: 'test-deployment-123456', - namespace: 'default', - uid: 'event-uid-1', - resourceVersion: '1000', - creationTimestamp: '2023-01-01T00:00:00Z', - }, - involvedObject: { - kind: 'Deployment', - namespace: 'default', - name: 'test-deployment', - uid: 'test-deployment-uid', // This matches a resource UID - apiVersion: 'apps/v1', - resourceVersion: '2000', - }, - reason: 'ScalingReplicaSet', - message: 'Scaled up replica set test-deployment-abc123 to 1', - source: { - component: 'deployment-controller', - }, - firstTimestamp: '2023-01-01T00:00:00Z', - lastTimestamp: '2023-01-01T00:00:00Z', - count: 1, - type: 'Normal', - reportingComponent: 'deployment-controller', - reportingInstance: '', + { + name: 'unrelated-pod-123456', + namespace: 'default', + uid: 'event-uid-3', + eventTime: new Date('2023-01-01T00:00:00Z'), + involvedObject: { + kind: 'Pod', + namespace: 'default', + name: 'unrelated-pod', + uid: 'unrelated-pod-uid', // This does NOT match any resource UIDs }, - { - metadata: { - name: 'unrelated-pod-123456', - namespace: 'default', - uid: 'event-uid-3', - resourceVersion: '1002', - creationTimestamp: '2023-01-01T00:00:00Z', - }, - involvedObject: { - kind: 'Pod', - namespace: 'default', - name: 'unrelated-pod', - uid: 'unrelated-pod-uid', // This does NOT match any resource UIDs - apiVersion: 'v1', - resourceVersion: '2002', - }, - reason: 'Scheduled', - message: 'Successfully assigned unrelated-pod to node', - source: { - component: 'default-scheduler', - }, - firstTimestamp: '2023-01-01T00:00:00Z', - lastTimestamp: '2023-01-01T00:00:00Z', - count: 1, - reportingComponent: 'scheduler', - reportingInstance: '', - }, - ], -}; + reason: 'Scheduled', + message: 'Successfully assigned unrelated-pod to node', + type: 'Normal', + firstTimestamp: new Date('2023-01-01T00:00:00Z'), + lastTimestamp: new Date('2023-01-01T00:00:00Z'), + count: 1, + }, +]; function renderComponent() { const user = new UserViewModel({ Username: 'user' }); @@ -229,7 +177,7 @@ describe('HelmEventsDatatable', () => { it('should correctly filter related events using the filterRelatedEvents function', () => { const filteredEvents = filterRelatedEvents( - mixedEventsResponse.items as Event[], + mixedEventsResponse as Event[], testResources ); diff --git a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/HelmEventsDatatable.tsx b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/HelmEventsDatatable.tsx index ced859f54..9dd161f5b 100644 --- a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/HelmEventsDatatable.tsx +++ b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/HelmEventsDatatable.tsx @@ -1,6 +1,6 @@ import { compact } from 'lodash'; -import { Event } from 'kubernetes-types/core/v1'; +import { Event } from '@/react/kubernetes/queries/types'; import { createStore } from '@/react/kubernetes/datatables/default-kube-datatable-store'; import { EventsDatatable } from '@/react/kubernetes/components/EventsDatatable'; import { useEvents } from '@/react/kubernetes/queries/useEvents'; diff --git a/app/react/kubernetes/queries/types.ts b/app/react/kubernetes/queries/types.ts new file mode 100644 index 000000000..4c8269c28 --- /dev/null +++ b/app/react/kubernetes/queries/types.ts @@ -0,0 +1,19 @@ +export type Event = { + type: string; + name: string; + reason: string; + message: string; + namespace: string; + eventTime: Date; + kind?: string; + count: number; + lastTimestamp?: Date; + firstTimestamp?: Date; + uid: string; + involvedObject: { + uid: string; + kind?: string; + name: string; + namespace: string; + }; +}; diff --git a/app/react/kubernetes/queries/useEvents.ts b/app/react/kubernetes/queries/useEvents.ts index f25c255db..47ea5c2c5 100644 --- a/app/react/kubernetes/queries/useEvents.ts +++ b/app/react/kubernetes/queries/useEvents.ts @@ -1,6 +1,6 @@ -import { EventList, Event } from 'kubernetes-types/core/v1'; import { useQuery } from '@tanstack/react-query'; +import { Event } from '@/react/kubernetes/queries/types'; import { EnvironmentId } from '@/react/portainer/environments/types'; import axios from '@/portainer/services/axios'; import { withGlobalError } from '@/react-tools/react-query'; @@ -13,10 +13,7 @@ type RequestOptions = { /** if undefined, events are fetched at the cluster scope */ namespace?: string; params?: { - /** https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/#label-selectors */ - labelSelector?: string; - /** https://kubernetes.io/docs/concepts/overview/working-with-objects/field-selectors */ - fieldSelector?: string; + resourceId?: string; }; }; @@ -44,13 +41,13 @@ async function getEvents( ): Promise { const { namespace, params } = options ?? {}; try { - const { data } = await axios.get( + const { data } = await axios.get( buildUrl(environmentId, namespace), { params, } ); - return data.items; + return data; } catch (e) { throw parseKubernetesAxiosError(e, 'Unable to retrieve events'); } @@ -96,6 +93,6 @@ export function useEventWarningsCount( function buildUrl(environmentId: EnvironmentId, namespace?: string) { return namespace - ? `/endpoints/${environmentId}/kubernetes/api/v1/namespaces/${namespace}/events` - : `/endpoints/${environmentId}/kubernetes/api/v1/events`; + ? `/kubernetes/${environmentId}/namespaces/${namespace}/events` + : `/kubernetes/${environmentId}/events`; }