From fe41ab198a719eed7072569085dff291bebcee8a Mon Sep 17 00:00:00 2001 From: iizitounene Date: Tue, 19 Aug 2025 17:24:36 +0200 Subject: [PATCH 1/7] feat: get via SSE and download pod logs --- api/openapi/v3/_api/pods/pods-server.go | 137 +++++++++++++++++++++ api/openapi/v3/definition/PodInfo.yaml | 77 ++++++++++++ api/openapi/v3/paths/k8s/pods.yaml | 39 ++++++ api/openapi/v3/paths/pods/logs.yaml | 70 +++++++++++ internal/common/constants/constants.go | 11 ++ internal/controllers/pod_controller.go | 130 +++++++++++++++++++ internal/controllers/register.go | 2 + internal/integrations/k8s/client/client.go | 17 ++- internal/integrations/k8s/client/k8s.go | 10 +- internal/integrations/k8s/client/pod.go | 104 ++++++++++++++++ internal/integrations/k8s/pod.go | 40 ++++++ internal/model/PodInfo.go | 23 ++++ internal/services/pod.go | 42 +++++++ 13 files changed, 692 insertions(+), 10 deletions(-) create mode 100644 api/openapi/v3/_api/pods/pods-server.go create mode 100644 api/openapi/v3/definition/PodInfo.yaml create mode 100644 api/openapi/v3/paths/k8s/pods.yaml create mode 100644 api/openapi/v3/paths/pods/logs.yaml create mode 100644 internal/controllers/pod_controller.go create mode 100644 internal/integrations/k8s/client/pod.go create mode 100644 internal/integrations/k8s/pod.go create mode 100644 internal/model/PodInfo.go create mode 100644 internal/services/pod.go diff --git a/api/openapi/v3/_api/pods/pods-server.go b/api/openapi/v3/_api/pods/pods-server.go new file mode 100644 index 0000000..d9fff40 --- /dev/null +++ b/api/openapi/v3/_api/pods/pods-server.go @@ -0,0 +1,137 @@ +// Package _pods provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.4.1 DO NOT EDIT. +package _pods + +import ( + "fmt" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/oapi-codegen/runtime" +) + +// GetLogsParams defines parameters for GetLogs. +type GetLogsParams struct { + // Download If true, downloads logs as a plain text file instead of streaming. + Download *bool `form:"download,omitempty" json:"download,omitempty"` + + // TailLines Number of log lines to show from the end of the logs. + TailLines *int `form:"tailLines,omitempty" json:"tailLines,omitempty"` +} + +// ServerInterface represents all server handlers. +type ServerInterface interface { + // Get the logs + // (GET /clusters/{clusterId}/namespaces/{namespace}/pods/{pod}/containers/{container}/logs) + GetLogs(c *gin.Context, clusterId string, namespace string, pod string, container string, params GetLogsParams) +} + +// ServerInterfaceWrapper converts contexts to parameters. +type ServerInterfaceWrapper struct { + Handler ServerInterface + HandlerMiddlewares []MiddlewareFunc + ErrorHandler func(*gin.Context, error, int) +} + +type MiddlewareFunc func(c *gin.Context) + +// GetLogs operation middleware +func (siw *ServerInterfaceWrapper) GetLogs(c *gin.Context) { + + var err error + + // ------------- Path parameter "clusterId" ------------- + var clusterId string + + err = runtime.BindStyledParameterWithOptions("simple", "clusterId", c.Param("clusterId"), &clusterId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter clusterId: %w", err), http.StatusBadRequest) + return + } + + // ------------- Path parameter "namespace" ------------- + var namespace string + + err = runtime.BindStyledParameterWithOptions("simple", "namespace", c.Param("namespace"), &namespace, runtime.BindStyledParameterOptions{Explode: false, Required: true}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter namespace: %w", err), http.StatusBadRequest) + return + } + + // ------------- Path parameter "pod" ------------- + var pod string + + err = runtime.BindStyledParameterWithOptions("simple", "pod", c.Param("pod"), &pod, runtime.BindStyledParameterOptions{Explode: false, Required: true}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter pod: %w", err), http.StatusBadRequest) + return + } + + // ------------- Path parameter "container" ------------- + var container string + + err = runtime.BindStyledParameterWithOptions("simple", "container", c.Param("container"), &container, runtime.BindStyledParameterOptions{Explode: false, Required: true}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter container: %w", err), http.StatusBadRequest) + return + } + + // Parameter object where we will unmarshal all parameters from the context + var params GetLogsParams + + // ------------- Optional query parameter "download" ------------- + + err = runtime.BindQueryParameter("form", true, false, "download", c.Request.URL.Query(), ¶ms.Download) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter download: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "tailLines" ------------- + + err = runtime.BindQueryParameter("form", true, false, "tailLines", c.Request.URL.Query(), ¶ms.TailLines) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter tailLines: %w", err), http.StatusBadRequest) + return + } + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.GetLogs(c, clusterId, namespace, pod, container, params) +} + +// GinServerOptions provides options for the Gin server. +type GinServerOptions struct { + BaseURL string + Middlewares []MiddlewareFunc + ErrorHandler func(*gin.Context, error, int) +} + +// RegisterHandlers creates http.Handler with routing matching OpenAPI spec. +func RegisterHandlers(router gin.IRouter, si ServerInterface) { + RegisterHandlersWithOptions(router, si, GinServerOptions{}) +} + +// RegisterHandlersWithOptions creates http.Handler with additional options +func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options GinServerOptions) { + errorHandler := options.ErrorHandler + if errorHandler == nil { + errorHandler = func(c *gin.Context, err error, statusCode int) { + c.JSON(statusCode, gin.H{"msg": err.Error()}) + } + } + + wrapper := ServerInterfaceWrapper{ + Handler: si, + HandlerMiddlewares: options.Middlewares, + ErrorHandler: errorHandler, + } + + router.GET(options.BaseURL+"/clusters/:clusterId/namespaces/:namespace/pods/:pod/containers/:container/logs", wrapper.GetLogs) +} diff --git a/api/openapi/v3/definition/PodInfo.yaml b/api/openapi/v3/definition/PodInfo.yaml new file mode 100644 index 0000000..17713d2 --- /dev/null +++ b/api/openapi/v3/definition/PodInfo.yaml @@ -0,0 +1,77 @@ +type: object +xml: + name: PodInfo +required: + - name + - namespace + - state + - createdAt + - health + - containers +properties: + name: + type: string + description: Name of the Pod + example: podinfo-768b456f7c-abc12 + namespace: + type: string + description: Namespace in which the Pod is deployed + example: default + state: + type: string + description: Pod state + example: Running + createdAt: + type: string + format: date-time + description: Pod creation date + example: "2025-04-25T11:48:48Z" + health: + type: string + description: Pod health status + example: Ready + containers: + type: array + description: List of containers in the Pod + items: + type: object + required: + - name + - image + - state + properties: + name: + type: string + description: Container name + example: container1 + image: + type: string + description: Container image + example: idirze/auth + state: + type: string + description: Container state + example: Running + reason: + type: string + description: Reason for the current state, if applicable + example: CrashLoopBackOff + message: + type: string + description: Message with additional details, if applicable + example: Back-off 5m0s restarting failed container podinfo +example: + name: podinfo-768b456f7c-abc12 + namespace: default + state: Running + createdAt: "2025-04-25T11:48:48Z" + health: Ready + containers: + - name: podinfo + image: stefanprodan/podinfo:latest + state: Waiting + reason: CrashLoopBackOff + message: Back-off 5m0s restarting failed container podinfo + - name: sidecar + image: myorg/sidecar:latest + state: Running diff --git a/api/openapi/v3/paths/k8s/pods.yaml b/api/openapi/v3/paths/k8s/pods.yaml new file mode 100644 index 0000000..57620a4 --- /dev/null +++ b/api/openapi/v3/paths/k8s/pods.yaml @@ -0,0 +1,39 @@ +get: + summary: Get the list of pods and their containers attached to a release + description: | + Returns all pods in a given namespace and release, including their container names. + tags: + - k8s + operationId: GetPods + parameters: + - in: path + name: clusterId + schema: + type: string + required: true + description: Kubernetes cluster ID + - in: path + name: namespace + schema: + type: string + required: true + description: Kubernetes namespace + - in: path + name: releaseName + schema: + type: string + required: true + description: KuboCD release name + responses: + '200': + description: A list of pods and their containers + content: + application/json: + schema: + $ref: '../../definition/PodInfo.yaml' + default: + description: Server error + content: + application/json: + schema: + $ref: '../../definition/ServerResponse.yaml' diff --git a/api/openapi/v3/paths/pods/logs.yaml b/api/openapi/v3/paths/pods/logs.yaml new file mode 100644 index 0000000..7289cbc --- /dev/null +++ b/api/openapi/v3/paths/pods/logs.yaml @@ -0,0 +1,70 @@ +get: + summary: Get the logs + description: | + Streams Kubernetes pod logs using Server-Sent Events (SSE), + allows downloading them as a plain text file, or + returns logs as JSON when requested via Accept=application/json header. + tags: + - pods + operationId: GetLogs + parameters: + - in: path + name: clusterId + schema: + type: string + required: true + description: Kubernetes cluster ID + - in: path + name: namespace + schema: + type: string + required: true + description: Kubernetes namespace + - in: path + name: pod + schema: + type: string + required: true + description: Pod name + - in: path + name: container + schema: + type: string + required: true + description: Container name + - in: query + name: download + schema: + type: boolean + default: false + description: If true, downloads logs as a plain text file instead of streaming. + - in: query + name: tailLines + schema: + type: integer + default: 100 + description: Number of log lines to show from the end of the logs. + responses: + '200': + description: Pod logs returned in different formats + content: + text/event-stream: + schema: + type: string + description: SSE stream of log lines (each event contains a JSON log entry). + text/plain: + schema: + type: string + description: Complete logs as a downloadable text file. + application/json: + schema: + type: array + items: + type: string + description: List of log lines returned when SSE is not supported (e.g., Swagger UI). + default: + description: Server error + content: + application/json: + schema: + $ref: '../../definition/ServerResponse.yaml' diff --git a/internal/common/constants/constants.go b/internal/common/constants/constants.go index a79c949..ef28d60 100644 --- a/internal/common/constants/constants.go +++ b/internal/common/constants/constants.go @@ -38,4 +38,15 @@ const ( K8SAuthCertificate = "AuthCertificate" K8SAuthBeaer = "AuthBearer" K8SInCluster = "InCluster" + + StateRunning = "Running" + StateWaiting = "Waiting" + StateTerminated = "Terminated" + StateUnknown = "Unknown" + + StateHealthy = "Healthy" + StateCompleted = "Completed" + StateNotReady = "NotReady" + StatePending = "Pending" + StateFailed = "Failed" ) diff --git a/internal/controllers/pod_controller.go b/internal/controllers/pod_controller.go new file mode 100644 index 0000000..306fda1 --- /dev/null +++ b/internal/controllers/pod_controller.go @@ -0,0 +1,130 @@ +/* + * Copyright 2025 okdp.io + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package controllers + +import ( + "bufio" + "fmt" + "io" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + + _api "github.com/okdp/okdp-server/api/openapi/v3/_api/pods" + log "github.com/okdp/okdp-server/internal/common/logging" + "github.com/okdp/okdp-server/internal/services" +) + +type IPodController struct { + podService *services.PodService +} + +func PodController() *IPodController { + return &IPodController{ + podService: services.NewPodService(), + } +} + +func (r IPodController) GetLogs(c *gin.Context, clusterID, namespace, pod, container string, params _api.GetLogsParams) { + accept := c.GetHeader("Accept") + isDownload := params.Download != nil && *params.Download + isSSE := strings.Contains(accept, "text/event-stream") + var tailLines *int64 + if !isDownload { + def := int64(100) + tailLines = &def + } + if params.TailLines != nil { + v := int64(*params.TailLines) + tailLines = &v + } + stream, err := r.podService.StreamLogs(clusterID, namespace, pod, container, tailLines, isSSE) + if err != nil { + c.AbortWithStatusJSON(err.Status, err) + return + } + defer stream.Close() + + switch { + case isSSE: + r.streamPodLogs(c, stream) + case isDownload: + r.downloadPodLogs(c, pod, container, stream) + default: + r.readPodLogs(c, stream) + } +} + +func (r IPodController) streamPodLogs(c *gin.Context, stream io.ReadCloser) { + log.Debug("Streaming pod logs using Server-Sent Events (SSE) ...") + c.Header("Content-Type", "text/event-stream") + c.Header("Cache-Control", "no-cache") + c.Header("Connection", "keep-alive") + + flusher, ok := c.Writer.(http.Flusher) + if !ok { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{ + "error": "streaming not supported", + }) + return + } + + scanner := bufio.NewScanner(stream) + for scanner.Scan() { + line := scanner.Text() + fmt.Fprintf(c.Writer, "data: %s\n\n", line) + flusher.Flush() + } + if err := scanner.Err(); err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{ + "error": "failed to stream logs", + "details": err.Error(), + }) + } +} + +func (r IPodController) downloadPodLogs(c *gin.Context, pod, container string, stream io.ReadCloser) { + log.Debug("Downloading pod logs as a plain text file ...") + filename := fmt.Sprintf("%s-%s-logs.log", pod, container) + c.Header("Content-Type", "text/plain; charset=utf-8") + c.Header("Content-Disposition", "attachment; filename="+filename) + c.Status(http.StatusOK) + if _, copyErr := io.Copy(c.Writer, stream); copyErr != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{ + "error": "failed to write logs", + "details": copyErr.Error(), + }) + } +} + +func (r IPodController) readPodLogs(c *gin.Context, stream io.ReadCloser) { + log.Debug("Returning logs as JSON array ...") + var logs []string + scanner := bufio.NewScanner(stream) + for scanner.Scan() { + logs = append(logs, scanner.Text()) + } + if err := scanner.Err(); err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{ + "error": "failed to read logs", + "details": err.Error(), + }) + return + } + c.JSON(http.StatusOK, logs) +} diff --git a/internal/controllers/register.go b/internal/controllers/register.go index 6d8dac1..74b1401 100644 --- a/internal/controllers/register.go +++ b/internal/controllers/register.go @@ -21,6 +21,7 @@ import ( _catalog "github.com/okdp/okdp-server/api/openapi/v3/_api/catalogs" _cluster "github.com/okdp/okdp-server/api/openapi/v3/_api/clusters" _k8s "github.com/okdp/okdp-server/api/openapi/v3/_api/k8s" + _pods "github.com/okdp/okdp-server/api/openapi/v3/_api/pods" _project "github.com/okdp/okdp-server/api/openapi/v3/_api/projects" _repositories "github.com/okdp/okdp-server/api/openapi/v3/_api/repositories" _user "github.com/okdp/okdp-server/api/openapi/v3/_api/users" @@ -43,6 +44,7 @@ func (g *Group) RegisterControllers() { _cluster.RegisterHandlers(g, ClusterController()) _repositories.RegisterHandlers(g, GitRepoController()) _k8s.RegisterHandlers(g, KuboCDController()) + _pods.RegisterHandlers(g, PodController()) } func (r *Router) RegisterSwaggerAPIDoc(swaggerConf config.Swagger) { diff --git a/internal/integrations/k8s/client/client.go b/internal/integrations/k8s/client/client.go index 84df365..09a07a4 100644 --- a/internal/integrations/k8s/client/client.go +++ b/internal/integrations/k8s/client/client.go @@ -24,11 +24,12 @@ import ( sourcev1 "github.com/fluxcd/source-controller/api/v1" corev1 "k8s.io/api/core/v1" apiruntime "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/kubernetes" "k8s.io/client-go/rest" restclient "k8s.io/client-go/rest" "k8s.io/client-go/tools/clientcmd" clientcmdapi "k8s.io/client-go/tools/clientcmd/api" - k8s "sigs.k8s.io/controller-runtime/pkg/client" + ctrlclient "sigs.k8s.io/controller-runtime/pkg/client" kubocdv1alpha1 "kubocd/api/v1alpha1" @@ -49,8 +50,9 @@ type KubeClients struct { } type KubeClient struct { - k8s.Client clusterID string + ctrlclient.Client + *kubernetes.Clientset } func GetClients() *KubeClients { @@ -66,14 +68,19 @@ func GetClients() *KubeClients { log.Fatal("Failed to get config for cluster ID '%s (%s)': %v", cluster.ID, cluster.Env, err) } - kubeClient, err := k8s.New(config, k8s.Options{ + ctrlClient, err := ctrlclient.New(config, ctrlclient.Options{ Scheme: newScheme(), }) if err != nil { - log.Fatal("Error creating new k8s client for cluster ID '%s (%s)': %v", cluster.ID, cluster.Env, err) + log.Fatal("Failed to initialize controller-runtime client for cluster for cluster ID '%s (%s)': %v", cluster.ID, cluster.Env, err) } - clients[utils.MapKey(cluster.ID)] = &KubeClient{kubeClient, cluster.ID} + k8sClientset, err := kubernetes.NewForConfig(config) + if err != nil { + log.Fatal("Failed to initialize client-go clientset for cluster ID '%s (%s)': %v", cluster.ID, cluster.Env, err) + } + + clients[utils.MapKey(cluster.ID)] = &KubeClient{cluster.ID, ctrlClient, k8sClientset} } instance = &KubeClients{clients: clients} diff --git a/internal/integrations/k8s/client/k8s.go b/internal/integrations/k8s/client/k8s.go index 8e8aee1..1350e6c 100644 --- a/internal/integrations/k8s/client/k8s.go +++ b/internal/integrations/k8s/client/k8s.go @@ -23,7 +23,7 @@ import ( corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - k8s "sigs.k8s.io/controller-runtime/pkg/client" + ctrlclient "sigs.k8s.io/controller-runtime/pkg/client" "github.com/okdp/okdp-server/internal/model" "github.com/okdp/okdp-server/internal/utils" @@ -55,7 +55,7 @@ func (c KubeClient) ListNamespaces(ctx context.Context) ([]*model.Namespace, *mo func (c KubeClient) GetNamespaceByName(ctx context.Context, clusterID string, name string) (*model.Namespace, *model.ServerResponse) { var ns corev1.Namespace - key := k8s.ObjectKey{Name: name} + key := ctrlclient.ObjectKey{Name: name} err := c.Get(ctx, key, &ns) if err != nil { @@ -80,7 +80,7 @@ func (c KubeClient) CreateNamespace(ctx context.Context, namespace *model.Namesp } var existing corev1.Namespace - err := c.Get(ctx, k8s.ObjectKey{Name: name}, &existing) + err := c.Get(ctx, ctrlclient.ObjectKey{Name: name}, &existing) if err == nil { return model.NewServerResponse(model.K8sClusterResponse). ConflictError("Namespace '%s' already exists '%s'", name) @@ -121,7 +121,7 @@ func (c KubeClient) UpdateNamespace(ctx context.Context, namespace *model.Namesp } var existing corev1.Namespace - err := c.Get(ctx, k8s.ObjectKey{Name: name}, &existing) + err := c.Get(ctx, ctrlclient.ObjectKey{Name: name}, &existing) if err != nil { if apierrors.IsNotFound(err) { return model.NewServerResponse(model.K8sClusterResponse). @@ -151,7 +151,7 @@ func (c KubeClient) DeleteNamespace(ctx context.Context, namespace string) *mode } var existing corev1.Namespace - err := c.Get(ctx, k8s.ObjectKey{Name: namespace}, &existing) + err := c.Get(ctx, ctrlclient.ObjectKey{Name: namespace}, &existing) if err != nil { if apierrors.IsNotFound(err) { return model.NewServerResponse(model.K8sClusterResponse). diff --git a/internal/integrations/k8s/client/pod.go b/internal/integrations/k8s/client/pod.go new file mode 100644 index 0000000..3c1d74a --- /dev/null +++ b/internal/integrations/k8s/client/pod.go @@ -0,0 +1,104 @@ +/* + * Copyright 2025 okdp.io + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package client + +import ( + "context" + "io" + "strings" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/okdp/okdp-server/internal/model" + "github.com/okdp/okdp-server/internal/utils" +) + +func (c KubeClient) GetPods(ctx context.Context, namespace string, releaseName string) ([]*model.PodInfo, *model.ServerResponse) { + release, err := c.GetRelease(ctx, namespace, releaseName) + if err != nil { + return nil, err + } + + podList, er := c.CoreV1().Pods(utils.DefaultIfEmpty(release.Spec.TargetNamespace, namespace)).List(ctx, metav1.ListOptions{}) + if er != nil { + return nil, model. + NewServerResponse(model.K8sClusterResponse). + UnprocessableEntity("failed to list pods from cluster '%s' matching release name '%s/%s', details: '%s'", c.clusterID, namespace, releaseName, er.Error()) + } + + var result []*model.PodInfo + + for _, pod := range podList.Items { + if strings.HasPrefix(pod.Name, releaseName) { + var containers []struct { + Image string `json:"image"` + Message *string `json:"message,omitempty"` + Name string `json:"name"` + Reason *string `json:"reason,omitempty"` + State string `json:"state"` + } + + for _, c := range pod.Spec.Containers { + cs := utils.GetContainerState(&pod, c.Name) + containers = append(containers, struct { + Image string `json:"image"` + Message *string `json:"message,omitempty"` + Name string `json:"name"` + Reason *string `json:"reason,omitempty"` + State string `json:"state"` + }{ + Image: c.Image, + Name: c.Name, + State: cs.State, + Reason: utils.EmptyToNil(cs.Reason), + Message: utils.EmptyToNil(cs.Message), + }) + } + + result = append(result, &model.PodInfo{ + Name: pod.Name, + Namespace: pod.Namespace, + CreatedAt: pod.CreationTimestamp.Time, + State: string(pod.Status.Phase), + Health: utils.GetPodHealth(&pod), + Containers: containers, + }) + } + } + + return utils.NilToEmptySlice(result), nil +} + +func (c KubeClient) StreamLogs(ctx context.Context, namespace, pod, container string, tailLines *int64, isSSE bool) (io.ReadCloser, *model.ServerResponse) { + podLogOpts := &corev1.PodLogOptions{ + Container: container, + Timestamps: false, + TailLines: tailLines, + Follow: isSSE, + } + + req := c.CoreV1().Pods(namespace).GetLogs(pod, podLogOpts) + stream, err := req.Stream(ctx) + if err != nil { + return nil, model. + NewServerResponse(model.K8sClusterResponse). + UnprocessableEntity("Failed to fetch logs from cluster '%s' for '%s/%s (%s)', details: '%s'", + c.clusterID, namespace, pod, container, err.Error()) + } + + return stream, nil +} diff --git a/internal/integrations/k8s/pod.go b/internal/integrations/k8s/pod.go new file mode 100644 index 0000000..e0c8768 --- /dev/null +++ b/internal/integrations/k8s/pod.go @@ -0,0 +1,40 @@ +/* + * Copyright 2025 okdp.io + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package k8s + +import ( + "context" + "io" + + "github.com/okdp/okdp-server/internal/model" +) + +func (r K8S) GetPods(clusterID, namespace, releaseName string) ([]*model.PodInfo, *model.ServerResponse) { + kubeClient, err := r.GetClient(clusterID) + if err != nil { + return nil, err + } + return kubeClient.GetPods(context.Background(), namespace, releaseName) +} + +func (r K8S) StreamLogs(clusterID, namespace, pod, container string, tailLines *int64, isSSE bool) (io.ReadCloser, *model.ServerResponse) { + kubeClient, err := r.GetClient(clusterID) + if err != nil { + return nil, err + } + return kubeClient.StreamLogs(context.Background(), namespace, pod, container, tailLines, isSSE) +} diff --git a/internal/model/PodInfo.go b/internal/model/PodInfo.go new file mode 100644 index 0000000..95d137e --- /dev/null +++ b/internal/model/PodInfo.go @@ -0,0 +1,23 @@ +/* + * Copyright 2025 okdp.io + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package model + +import ( + "github.com/okdp/okdp-server/api/openapi/v3/_api" +) + +type PodInfo _api.PodInfo diff --git a/internal/services/pod.go b/internal/services/pod.go new file mode 100644 index 0000000..9fecabe --- /dev/null +++ b/internal/services/pod.go @@ -0,0 +1,42 @@ +/* + * Copyright 2025 okdp.io + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package services + +import ( + "io" + + "github.com/okdp/okdp-server/internal/integrations/k8s" + "github.com/okdp/okdp-server/internal/model" +) + +type PodService struct { + pod *k8s.K8S +} + +func NewPodService() *PodService { + return &PodService{ + pod: k8s.NewK8S(), + } +} + +func (s PodService) GetPods(clusterID, namespace, releaseName string) ([]*model.PodInfo, *model.ServerResponse) { + return s.pod.GetPods(clusterID, namespace, releaseName) +} + +func (s PodService) StreamLogs(clusterID, namespace, pod, container string, tailLines *int64, isSSE bool) (io.ReadCloser, *model.ServerResponse) { + return s.pod.StreamLogs(clusterID, namespace, pod, container, tailLines, isSSE) +} From 18bf03e256df7e39ba363160c41cf89443f221cb Mon Sep 17 00:00:00 2001 From: iizitounene Date: Tue, 19 Aug 2025 17:33:40 +0200 Subject: [PATCH 2/7] feat: List KuboCD release pods and events --- api/openapi/v3/_api/k8s/k8s-server.go | 92 ++++++++ api/openapi/v3/_api/spec.go | 239 +++++++++++---------- api/openapi/v3/_api/types.go | 45 ++++ api/openapi/v3/api.yaml | 14 ++ api/openapi/v3/paths/k8s/events.yaml | 46 ++++ internal/controllers/kubocd_controller.go | 35 ++- internal/integrations/k8s/client/kubocd.go | 42 +++- internal/integrations/k8s/kubocd.go | 8 + internal/services/kubocd.go | 4 + 9 files changed, 404 insertions(+), 121 deletions(-) create mode 100644 api/openapi/v3/paths/k8s/events.yaml diff --git a/api/openapi/v3/_api/k8s/k8s-server.go b/api/openapi/v3/_api/k8s/k8s-server.go index b736b66..a06281a 100644 --- a/api/openapi/v3/_api/k8s/k8s-server.go +++ b/api/openapi/v3/_api/k8s/k8s-server.go @@ -667,6 +667,12 @@ type ServerInterface interface { // Get the deployed release by name // (GET /clusters/{clusterId}/namespaces/{namespace}/releases/{releaseName}) GetK8sRelease(c *gin.Context, clusterId string, namespace string, releaseName string) + // Get Kubernetes events for a release + // (GET /clusters/{clusterId}/namespaces/{namespace}/releases/{releaseName}/events) + GetEventsRelease(c *gin.Context, clusterId string, namespace string, releaseName string) + // Get the list of pods and their containers attached to a release + // (GET /clusters/{clusterId}/namespaces/{namespace}/releases/{releaseName}/pods) + GetPods(c *gin.Context, clusterId string, namespace string, releaseName string) // Get the release status // (GET /clusters/{clusterId}/namespaces/{namespace}/releases/{releaseName}/status) GetK8sReleaseStatus(c *gin.Context, clusterId string, namespace string, releaseName string) @@ -886,6 +892,90 @@ func (siw *ServerInterfaceWrapper) GetK8sRelease(c *gin.Context) { siw.Handler.GetK8sRelease(c, clusterId, namespace, releaseName) } +// GetEventsRelease operation middleware +func (siw *ServerInterfaceWrapper) GetEventsRelease(c *gin.Context) { + + var err error + + // ------------- Path parameter "clusterId" ------------- + var clusterId string + + err = runtime.BindStyledParameterWithOptions("simple", "clusterId", c.Param("clusterId"), &clusterId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter clusterId: %w", err), http.StatusBadRequest) + return + } + + // ------------- Path parameter "namespace" ------------- + var namespace string + + err = runtime.BindStyledParameterWithOptions("simple", "namespace", c.Param("namespace"), &namespace, runtime.BindStyledParameterOptions{Explode: false, Required: true}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter namespace: %w", err), http.StatusBadRequest) + return + } + + // ------------- Path parameter "releaseName" ------------- + var releaseName string + + err = runtime.BindStyledParameterWithOptions("simple", "releaseName", c.Param("releaseName"), &releaseName, runtime.BindStyledParameterOptions{Explode: false, Required: true}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter releaseName: %w", err), http.StatusBadRequest) + return + } + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.GetEventsRelease(c, clusterId, namespace, releaseName) +} + +// GetPods operation middleware +func (siw *ServerInterfaceWrapper) GetPods(c *gin.Context) { + + var err error + + // ------------- Path parameter "clusterId" ------------- + var clusterId string + + err = runtime.BindStyledParameterWithOptions("simple", "clusterId", c.Param("clusterId"), &clusterId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter clusterId: %w", err), http.StatusBadRequest) + return + } + + // ------------- Path parameter "namespace" ------------- + var namespace string + + err = runtime.BindStyledParameterWithOptions("simple", "namespace", c.Param("namespace"), &namespace, runtime.BindStyledParameterOptions{Explode: false, Required: true}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter namespace: %w", err), http.StatusBadRequest) + return + } + + // ------------- Path parameter "releaseName" ------------- + var releaseName string + + err = runtime.BindStyledParameterWithOptions("simple", "releaseName", c.Param("releaseName"), &releaseName, runtime.BindStyledParameterOptions{Explode: false, Required: true}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter releaseName: %w", err), http.StatusBadRequest) + return + } + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.GetPods(c, clusterId, namespace, releaseName) +} + // GetK8sReleaseStatus operation middleware func (siw *ServerInterfaceWrapper) GetK8sReleaseStatus(c *gin.Context) { @@ -960,5 +1050,7 @@ func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options router.PUT(options.BaseURL+"/clusters/:clusterId/namespaces/:namespace/releases", wrapper.UpdateK8sRelease) router.DELETE(options.BaseURL+"/clusters/:clusterId/namespaces/:namespace/releases/:releaseName", wrapper.DeleteK8sRelease) router.GET(options.BaseURL+"/clusters/:clusterId/namespaces/:namespace/releases/:releaseName", wrapper.GetK8sRelease) + router.GET(options.BaseURL+"/clusters/:clusterId/namespaces/:namespace/releases/:releaseName/events", wrapper.GetEventsRelease) + router.GET(options.BaseURL+"/clusters/:clusterId/namespaces/:namespace/releases/:releaseName/pods", wrapper.GetPods) router.GET(options.BaseURL+"/clusters/:clusterId/namespaces/:namespace/releases/:releaseName/status", wrapper.GetK8sReleaseStatus) } diff --git a/api/openapi/v3/_api/spec.go b/api/openapi/v3/_api/spec.go index 96af8cb..4ede577 100644 --- a/api/openapi/v3/_api/spec.go +++ b/api/openapi/v3/_api/spec.go @@ -18,117 +18,134 @@ import ( // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "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", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/api/openapi/v3/_api/types.go b/api/openapi/v3/_api/types.go index 31ac678..c2978ca 100644 --- a/api/openapi/v3/_api/types.go +++ b/api/openapi/v3/_api/types.go @@ -264,6 +264,42 @@ type Package struct { Versions []string `json:"versions"` } +// PodInfo defines model for PodInfo. +type PodInfo struct { + // Containers List of containers in the Pod + Containers []struct { + // Image Container image + Image string `json:"image"` + + // Message Message with additional details, if applicable + Message *string `json:"message,omitempty"` + + // Name Container name + Name string `json:"name"` + + // Reason Reason for the current state, if applicable + Reason *string `json:"reason,omitempty"` + + // State Container state + State string `json:"state"` + } `json:"containers"` + + // CreatedAt Pod creation date + CreatedAt time.Time `json:"createdAt"` + + // Health Pod health status + Health string `json:"health"` + + // Name Name of the Pod + Name string `json:"name"` + + // Namespace Namespace in which the Pod is deployed + Namespace string `json:"namespace"` + + // State Pod state + State string `json:"state"` +} + // Project defines model for Project. type Project struct { CreationTimestamp *time.Time `json:"creationTimestamp,omitempty"` @@ -1299,6 +1335,15 @@ type UpdateGitReleaseJSONBodySpecPackageProvider string // UpdateGitReleaseJSONBodySpecPackageVerifyProvider defines parameters for UpdateGitRelease. type UpdateGitReleaseJSONBodySpecPackageVerifyProvider string +// GetLogsParams defines parameters for GetLogs. +type GetLogsParams struct { + // Download If true, downloads logs as a plain text file instead of streaming. + Download *bool `form:"download,omitempty" json:"download,omitempty"` + + // TailLines Number of log lines to show from the end of the logs. + TailLines *int `form:"tailLines,omitempty" json:"tailLines,omitempty"` +} + // CreateK8sReleaseJSONBody defines parameters for CreateK8sRelease. type CreateK8sReleaseJSONBody struct { // ApiVersion APIVersion defines the versioned schema of this representation of an object. diff --git a/api/openapi/v3/api.yaml b/api/openapi/v3/api.yaml index 5d4d690..d49e7c4 100644 --- a/api/openapi/v3/api.yaml +++ b/api/openapi/v3/api.yaml @@ -42,6 +42,10 @@ tags: description: KuboCD Git Releases externalDocs: url: https://github.com/okdp/okdp-server + - name: pods + description: Kubernetes pods + externalDocs: + url: https://github.com/okdp/okdp-server paths: ### Users @@ -97,6 +101,14 @@ paths: $ref: ./paths/k8s/release-by-name.yaml /clusters/{clusterId}/namespaces/{namespace}/releases/{releaseName}/status: $ref: ./paths/k8s/release-status.yaml + /clusters/{clusterId}/namespaces/{namespace}/releases/{releaseName}/events: + $ref: ./paths/k8s/events.yaml + /clusters/{clusterId}/namespaces/{namespace}/releases/{releaseName}/pods: + $ref: ./paths/k8s/pods.yaml + + ### pods + /clusters/{clusterId}/namespaces/{namespace}/pods/{pod}/containers/{container}/logs: + $ref: ./paths/pods/logs.yaml components: schemas: @@ -122,6 +134,8 @@ components: $ref: './definition/GitRepository.yaml' GitCommit: $ref: './definition/GitCommit.yaml' + PodInfo: + $ref: './definition/PodInfo.yaml' ServerResponse: $ref: './definition/ServerResponse.yaml' diff --git a/api/openapi/v3/paths/k8s/events.yaml b/api/openapi/v3/paths/k8s/events.yaml new file mode 100644 index 0000000..3340a78 --- /dev/null +++ b/api/openapi/v3/paths/k8s/events.yaml @@ -0,0 +1,46 @@ +get: + summary: Get Kubernetes events for a release + description: | + Returns Kubernetes events associated with a KuboCD release as a JSON array. + This endpoint mimics the behavior of `kubectl describe release ` and is suitable for browser/Swagger usage. + tags: + - k8s + operationId: GetEventsRelease + parameters: + - in: path + name: clusterId + schema: + type: string + required: true + description: Kubernetes cluster ID + - in: path + name: namespace + schema: + type: string + required: true + description: Kubernetes namespace + - in: path + name: releaseName + schema: + type: string + required: true + description: KuboCD release name + responses: + '200': + description: | + Returns Kubernetes events for the specified release as a JSON array. + content: + application/json: + schema: + type: array + items: + type: object + additionalProperties: true + description: | + JSON array of Kubernetes event objects for the specified release. + default: + description: Server error + content: + application/json: + schema: + $ref: '../../definition/ServerResponse.yaml' diff --git a/internal/controllers/kubocd_controller.go b/internal/controllers/kubocd_controller.go index bd80b16..e4116ef 100644 --- a/internal/controllers/kubocd_controller.go +++ b/internal/controllers/kubocd_controller.go @@ -14,11 +14,13 @@ import ( type IKuboCDController struct { k8sService *services.KuboCDService + podService *services.PodService } func KuboCDController() *IKuboCDController { return &IKuboCDController{ k8sService: services.NewKuboCDService(), + podService: services.NewPodService(), } } @@ -26,7 +28,7 @@ func (r IKuboCDController) ListK8sReleases(c *gin.Context, clusterID string, nam releasesInfo, err := r.k8sService.ListReleases(clusterID, namespace) if err != nil { log.Error("Unable to get releases from Kubernetes cluster '%s' on namespace '%s', details: %+v", clusterID, namespace, err) - c.JSON(err.Status, err) + c.AbortWithStatusJSON(err.Status, err) return } c.JSON(http.StatusOK, releasesInfo) @@ -36,7 +38,7 @@ func (r IKuboCDController) GetK8sRelease(c *gin.Context, clusterID string, names release, err := r.k8sService.GetRelease(clusterID, namespace, releaseName) if err != nil { log.Error("Unable to get release from Kubernetes cluster '%s' on namespace '%s', details: %+v", clusterID, namespace, err) - c.JSON(err.Status, err) + c.AbortWithStatusJSON(err.Status, err) return } c.JSON(http.StatusOK, release) @@ -46,7 +48,7 @@ func (r IKuboCDController) GetK8sReleaseStatus(c *gin.Context, clusterID string, release, err := r.k8sService.GetReleaseStatus(clusterID, namespace, releaseName) if err != nil { log.Error("Unable to get release status from Kubernetes cluster '%s' on namespace '%s', details: %+v", clusterID, namespace, err) - c.JSON(err.Status, err) + c.AbortWithStatusJSON(err.Status, err) return } c.JSON(http.StatusOK, release) @@ -57,7 +59,7 @@ func (r IKuboCDController) CreateK8sRelease(c *gin.Context, clusterID string, na if err := c.ShouldBindJSON(&release); err != nil { resp := model.NewServerResponse(model.OkdpServerResponse).BadRequest("%+v", err.Error()) - c.JSON(resp.Status, resp) + c.AbortWithStatusJSON(resp.Status, resp) return } @@ -72,7 +74,7 @@ func (r IKuboCDController) UpdateK8sRelease(c *gin.Context, clusterID string, na if err := c.ShouldBindJSON(&release); err != nil { resp := model.NewServerResponse(model.OkdpServerResponse).BadRequest("%+v", err.Error()) - c.JSON(resp.Status, resp) + c.AbortWithStatusJSON(resp.Status, resp) return } @@ -84,3 +86,26 @@ func (r IKuboCDController) DeleteK8sRelease(c *gin.Context, clusterID string, na response := r.k8sService.DeleteRelease(clusterID, namespace, releaseName) c.JSON(response.Status, response) } + +func (r IKuboCDController) GetPods(c *gin.Context, clusterID string, namespace string, releaseName string) { + podInfos, err := r.podService.GetPods(clusterID, namespace, releaseName) + if err != nil { + log.Error("Unable to get pods from Kubernetes cluster '%s' for release '%s/%s', details: %+v", clusterID, releaseName, namespace, err) + c.AbortWithStatusJSON(err.Status, err) + return + } + c.JSON(http.StatusOK, podInfos) +} + +func (r IKuboCDController) GetEventsRelease(c *gin.Context, clusterID string, namespace string, releaseName string) { + + events, err := r.k8sService.ListEventsRelease(clusterID, namespace, releaseName) + if err != nil { + c.AbortWithStatusJSON(err.Status, gin.H{ + "error": "failed to list events", + "details": err, + }) + return + } + c.JSON(200, events) +} diff --git a/internal/integrations/k8s/client/kubocd.go b/internal/integrations/k8s/client/kubocd.go index 88b8251..65aa8e1 100644 --- a/internal/integrations/k8s/client/kubocd.go +++ b/internal/integrations/k8s/client/kubocd.go @@ -18,9 +18,11 @@ package client import ( "context" + "encoding/json" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - k8s "sigs.k8s.io/controller-runtime/pkg/client" + "k8s.io/apimachinery/pkg/fields" + ctrlclient "sigs.k8s.io/controller-runtime/pkg/client" kubocdv1alpha1 "kubocd/api/v1alpha1" @@ -48,7 +50,7 @@ func (c KubeClient) ListReleases(ctx context.Context, namespaces ...string) ([]* } func (c KubeClient) GetRelease(ctx context.Context, namespace string, releaseName string) (*model.Release, *model.ServerResponse) { - releaseKey := k8s.ObjectKey{ + releaseKey := ctrlclient.ObjectKey{ Namespace: namespace, Name: releaseName, } @@ -67,7 +69,7 @@ func (c KubeClient) GetRelease(ctx context.Context, namespace string, releaseNam } func (c KubeClient) GetReleaseStatus(ctx context.Context, namespace string, releaseName string) (*model.ReleaseStatus, *model.ServerResponse) { - releaseKey := k8s.ObjectKey{ + releaseKey := ctrlclient.ObjectKey{ Namespace: namespace, Name: releaseName, } @@ -90,7 +92,7 @@ func (c KubeClient) CreateRelease(ctx context.Context, namespace string, release var err error if dryRun { - err = c.Create(ctx, &rel, &k8s.CreateOptions{DryRun: []string{constants.All}}) + err = c.Create(ctx, &rel, &ctrlclient.CreateOptions{DryRun: []string{constants.All}}) } else { err = c.Create(ctx, &rel) } @@ -109,7 +111,7 @@ func (c KubeClient) UpdateRelease(ctx context.Context, namespace string, release var err error if dryRun { - err = c.Update(ctx, &rel, &k8s.UpdateOptions{DryRun: []string{constants.All}}) + err = c.Update(ctx, &rel, &ctrlclient.UpdateOptions{DryRun: []string{constants.All}}) } else { err = c.Update(ctx, &rel) } @@ -140,3 +142,33 @@ func (c KubeClient) DeleteRelease(ctx context.Context, namespace string, release return model.NewServerResponse(model.K8sClusterResponse).Deleted("Successfuly deleted release %s", releaseName) } + +func (c KubeClient) ListEventsRelease(ctx context.Context, namespace, releaseName string) ([]map[string]interface{}, *model.ServerResponse) { + fieldSelector := fields.AndSelectors( + fields.OneTermEqualSelector("involvedObject.name", releaseName), + fields.OneTermEqualSelector("involvedObject.kind", "Release"), + ).String() + + eventList, err := c.CoreV1().Events(namespace).List(ctx, metav1.ListOptions{ + FieldSelector: fieldSelector, + }) + if err != nil { + return nil, model. + NewServerResponse(model.K8sClusterResponse). + UnprocessableEntity("Failed to list events for release '%s/%s': %s", namespace, releaseName, err.Error()) + } + + events := make([]map[string]interface{}, 0, len(eventList.Items)) + for _, event := range eventList.Items { + b, err := json.Marshal(event) + if err != nil { + continue + } + var m map[string]interface{} + if err := json.Unmarshal(b, &m); err != nil { + continue + } + events = append(events, m) + } + return events, nil +} diff --git a/internal/integrations/k8s/kubocd.go b/internal/integrations/k8s/kubocd.go index fe06c9c..163bfb1 100644 --- a/internal/integrations/k8s/kubocd.go +++ b/internal/integrations/k8s/kubocd.go @@ -69,3 +69,11 @@ func (r K8S) DeleteRelease(clusterID string, namespace string, releaseName strin } return kubeClient.DeleteRelease(context.Background(), namespace, releaseName) } + +func (r K8S) ListEventsRelease(clusterID string, namespace, releaseName string) ([]map[string]interface{}, *model.ServerResponse) { + kubeClient, err := r.GetClient(clusterID) + if err != nil { + return nil, err + } + return kubeClient.ListEventsRelease(context.Background(), namespace, releaseName) +} diff --git a/internal/services/kubocd.go b/internal/services/kubocd.go index c0311f5..a0834fc 100644 --- a/internal/services/kubocd.go +++ b/internal/services/kubocd.go @@ -55,3 +55,7 @@ func (s KuboCDService) UpdateRelease(clusterID string, namespace string, release func (s KuboCDService) DeleteRelease(clusterID string, namespace string, releaseName string) *model.ServerResponse { return s.kubocd.DeleteRelease(clusterID, namespace, releaseName) } + +func (s KuboCDService) ListEventsRelease(clusterID string, namespace, releaseName string) ([]map[string]interface{}, *model.ServerResponse) { + return s.kubocd.ListEventsRelease(clusterID, namespace, releaseName) +} From 1cd86df1314f62a868f9a094061d5b125b9baba4 Mon Sep 17 00:00:00 2001 From: iizitounene Date: Tue, 19 Aug 2025 17:34:57 +0200 Subject: [PATCH 3/7] refactor: update go version and modules --- Dockerfile | 2 +- go.mod | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 9719b05..8a4acd9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -ARG GO_VERSION=1.24 +ARG GO_VERSION=1.24.5 FROM golang:${GO_VERSION} AS go-build diff --git a/go.mod b/go.mod index c1f6a24..72fef83 100644 --- a/go.mod +++ b/go.mod @@ -98,6 +98,7 @@ require ( github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/perimeterx/marshmallow v1.1.5 // indirect github.com/pjbgf/sha1cd v0.3.2 // indirect + github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/sagikazarmark/locafero v0.9.0 // indirect github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect @@ -122,6 +123,7 @@ require ( golang.org/x/time v0.11.0 // indirect golang.org/x/tools v0.33.0 // indirect google.golang.org/protobuf v1.36.6 // indirect + gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect From 07e27ecbdb01d25abcf2192a2dd7fe3256fc6179 Mon Sep 17 00:00:00 2001 From: iizitounene Date: Tue, 19 Aug 2025 17:35:18 +0200 Subject: [PATCH 4/7] refactor: refactoring --- .local/application-local.yaml | 25 +++++++- internal/controllers/catalog_controller.go | 10 +-- internal/controllers/cluster_controller.go | 10 +-- internal/controllers/git_controller.go | 23 +++---- internal/controllers/project_controller.go | 8 +-- internal/integrations/k8s/client/fluxcd.go | 6 +- internal/integrations/k8s/client/secret.go | 4 +- internal/utils/k8s_utils.go | 75 ++++++++++++++++++++++ internal/utils/parameter_utils.go | 19 ++++++ 9 files changed, 148 insertions(+), 32 deletions(-) diff --git a/.local/application-local.yaml b/.local/application-local.yaml index 163e3b6..7597394 100644 --- a/.local/application-local.yaml +++ b/.local/application-local.yaml @@ -132,6 +132,27 @@ catalog: packages: - name: podinfo + - id: Dataviz + name: Dataviz catalog + description: My Dataviz packages + repoUrl: quay.io/okdp/sandbox-packages + packages: + - name: superset + + - id: Notebooks + name: Notebooks catalog + description: My Notebooks packages + repoUrl: quay.io/okdp/sandbox-packages + packages: + - name: jupyterhub + + - id: Spark + name: Spark catalog + description: My Spark packages + repoUrl: quay.io/okdp/sandbox-packages + packages: + - name: spark-history-server + clusters: - id: kubo2 name: My k8s cluster 1 @@ -158,10 +179,10 @@ clusters: auth: # inCluster: true kubeconfig: - apiServer: https://host.docker.internal:59370 + apiServer: https://host.docker.internal:55192 path: /tmp/.kube/config # When not provided, use current context - context: kind-kind + context: kind-okdp-sandbox insecureSkipTlsVerify: true # certificate: # apiServer: https://k8s-api-server-url:6443 diff --git a/internal/controllers/catalog_controller.go b/internal/controllers/catalog_controller.go index 0f12e2d..2515acc 100644 --- a/internal/controllers/catalog_controller.go +++ b/internal/controllers/catalog_controller.go @@ -43,7 +43,7 @@ func (r ICatalogController) GetCatalog(c *gin.Context, catalogID string) { catalog, err := r.catalogService.GetCatalog(catalogID) if err != nil { log.Error("Unable to find the Catalog with ID '%s', details: %+v", catalogID, err) - c.JSON(err.Status, err) + c.AbortWithStatusJSON(err.Status, err) return } c.JSON(http.StatusOK, catalog) @@ -53,7 +53,7 @@ func (r ICatalogController) ListPackages(c *gin.Context, catalogID string) { packages, err := r.catalogService.GetPackages(catalogID) if err != nil { log.Error("Unable to find the packages with Catalog ID '%s', details: %+v", catalogID, err) - c.JSON(err.Status, err) + c.AbortWithStatusJSON(err.Status, err) return } c.JSON(http.StatusOK, packages) @@ -63,7 +63,7 @@ func (r ICatalogController) GetPackage(c *gin.Context, catalogID string, name st result, err := r.catalogService.GetPackage(catalogID, name) if err != nil { log.Error("Unable to find the package '%s' with Catalog ID '%s', details: %+v", name, catalogID, err) - c.JSON(err.Status, err) + c.AbortWithStatusJSON(err.Status, err) return } c.JSON(http.StatusOK, result) @@ -77,7 +77,7 @@ func (r ICatalogController) GetPackageDefinition(c *gin.Context, catalogID strin definition, err := r.catalogService.GetPackageDefinition(catalogID, name, version) if err != nil { log.Error("Unable to find the package definition for package '%s:%s' with Catalog ID '%s', details: %+v", name, version, catalogID, err) - c.JSON(err.Status, err) + c.AbortWithStatusJSON(err.Status, err) return } c.JSON(http.StatusOK, definition) @@ -87,7 +87,7 @@ func (r ICatalogController) GetPackageSchema(c *gin.Context, catalogID string, n definition, err := r.catalogService.GetPackageDefinition(catalogID, name, version) if err != nil { log.Error("Unable to find the package definition for package '%s:%s' with Catalog ID '%s', details: %+v", name, version, catalogID, err) - c.JSON(err.Status, err) + c.AbortWithStatusJSON(err.Status, err) return } schema, ok := definition["schema"] diff --git a/internal/controllers/cluster_controller.go b/internal/controllers/cluster_controller.go index bac1351..bfee864 100644 --- a/internal/controllers/cluster_controller.go +++ b/internal/controllers/cluster_controller.go @@ -44,7 +44,7 @@ func (r IClusterController) GetCluster(c *gin.Context, clusterID string) { cluster, err := r.clusterService.GetCluster(clusterID) if err != nil { log.Error("%+v", clusterID, err) - c.JSON(err.Status, err) + c.AbortWithStatusJSON(err.Status, err) return } c.JSON(http.StatusOK, cluster) @@ -54,7 +54,7 @@ func (r IClusterController) ListNamespaces(c *gin.Context, clusterID string) { namespaces, err := r.clusterService.ListNamespaces(clusterID) if err != nil { log.Error("%+v", clusterID, err) - c.JSON(err.Status, err) + c.AbortWithStatusJSON(err.Status, err) return } c.JSON(http.StatusOK, namespaces) @@ -64,7 +64,7 @@ func (r IClusterController) GetNamespace(c *gin.Context, clusterID string, names ns, err := r.clusterService.GetNamespaceByName(clusterID, namespace) if err != nil { log.Error("%+v", clusterID, err) - c.JSON(err.Status, err) + c.AbortWithStatusJSON(err.Status, err) return } c.JSON(http.StatusOK, ns) @@ -75,7 +75,7 @@ func (r IClusterController) CreateNamespace(c *gin.Context, clusterID string) { if err := c.ShouldBindJSON(&namespace); err != nil { resp := model.NewServerResponse(model.OkdpServerResponse).BadRequest("%+v", err.Error()) - c.JSON(resp.Status, resp) + c.AbortWithStatusJSON(resp.Status, resp) return } @@ -90,7 +90,7 @@ func (r IClusterController) UpdateNamespace(c *gin.Context, clusterID string) { if err := c.ShouldBindJSON(&namespace); err != nil { resp := model.NewServerResponse(model.OkdpServerResponse).BadRequest("%+v", err.Error()) - c.JSON(resp.Status, resp) + c.AbortWithStatusJSON(resp.Status, resp) return } diff --git a/internal/controllers/git_controller.go b/internal/controllers/git_controller.go index 5ec4c1c..686c519 100644 --- a/internal/controllers/git_controller.go +++ b/internal/controllers/git_controller.go @@ -40,7 +40,7 @@ func (r IGitRepoController) ListGitRepos(c *gin.Context, clusterID string, names gitRepos, err := r.gitRepoService.ListGitRepos(clusterID, namespace) if err != nil { log.Error("Unable to list Git repos on namespace '%s' with cluster ID '%s', details: %+v", namespace, clusterID, err) - c.JSON(err.Status, err) + c.AbortWithStatusJSON(err.Status, err) return } c.JSON(http.StatusOK, gitRepos) @@ -50,7 +50,7 @@ func (r IGitRepoController) GetGitRepo(c *gin.Context, clusterID string, namespa gitRepo, err := r.gitRepoService.GetGitRepo(clusterID, namespace, kustomizationName) if err != nil { log.Error("Unable to find Git repo '%s' on namespace '%s' with cluster id '%s', details: %+v", kustomizationName, namespace, clusterID, err) - c.JSON(err.Status, err) + c.AbortWithStatusJSON(err.Status, err) return } c.JSON(http.StatusOK, gitRepo) @@ -60,7 +60,7 @@ func (r IGitRepoController) ListGitReleases(c *gin.Context, clusterID string, na releasesInfo, err := r.gitRepoService.ListReleases(clusterID, namespace, kustomizationName) if err != nil { log.Error("Unable to get releases from Git repo '%s/%s' on cluster ID '%s', details: %+v", namespace, kustomizationName, clusterID, err) - c.JSON(err.Status, err) + c.AbortWithStatusJSON(err.Status, err) return } c.JSON(http.StatusOK, releasesInfo) @@ -70,7 +70,7 @@ func (r IGitRepoController) GetGitRelease(c *gin.Context, clusterID string, name release, err := r.gitRepoService.GetRelease(clusterID, namespace, kustomizationName, releaseName) if err != nil { log.Error("Unable to get release from Git repo '%s/%s' on cluster ID '%s', details: %+v", namespace, kustomizationName, clusterID, err) - c.JSON(err.Status, err) + c.AbortWithStatusJSON(err.Status, err) return } c.JSON(http.StatusOK, release) @@ -80,12 +80,12 @@ func (r IGitRepoController) CreateGitRelease(c *gin.Context, clusterID string, n var release model.Release userInfo, err := GetUserInfo(c) if err != nil { - c.JSON(err.Status, err) + c.AbortWithStatusJSON(err.Status, err) } if err := c.ShouldBindJSON(&release); err != nil { resp := model.NewServerResponse(model.OkdpServerResponse).BadRequest("%+v", err.Error()) - c.JSON(resp.Status, resp) + c.AbortWithStatusJSON(resp.Status, resp) return } @@ -96,7 +96,7 @@ func (r IGitRepoController) CreateGitRelease(c *gin.Context, clusterID string, n resp, err := r.gitRepoService.CreateGitRelease(clusterID, namespace, kustomizationName, &release, commitOpts) if err != nil { - c.JSON(err.Status, err) + c.AbortWithStatusJSON(err.Status, err) return } @@ -108,12 +108,12 @@ func (r IGitRepoController) UpdateGitRelease(c *gin.Context, clusterID string, n var release model.Release userInfo, err := GetUserInfo(c) if err != nil { - c.JSON(err.Status, err) + c.AbortWithStatusJSON(err.Status, err) } if err := c.ShouldBindJSON(&release); err != nil { resp := model.NewServerResponse(model.OkdpServerResponse).BadRequest("%+v", err.Error()) - c.JSON(resp.Status, resp) + c.AbortWithStatusJSON(resp.Status, resp) return } @@ -124,7 +124,7 @@ func (r IGitRepoController) UpdateGitRelease(c *gin.Context, clusterID string, n resp, err := r.gitRepoService.UpdateGitRelease(clusterID, namespace, kustomizationName, &release, commitOpts) if err != nil { - c.JSON(err.Status, err) + c.AbortWithStatusJSON(err.Status, err) return } @@ -135,7 +135,8 @@ func (r IGitRepoController) DeleteGitRelease(c *gin.Context, clusterID string, n userInfo, err := GetUserInfo(c) if err != nil { - c.JSON(err.Status, err) + c.AbortWithStatusJSON(err.Status, err) + return } msg := fmt.Sprintf("Delete KuboCD release %s/%s on cluster id %s", namespace, releaseName, clusterID) diff --git a/internal/controllers/project_controller.go b/internal/controllers/project_controller.go index 81ba6b0..b569ecb 100644 --- a/internal/controllers/project_controller.go +++ b/internal/controllers/project_controller.go @@ -40,7 +40,7 @@ func (r IProjectController) ListProjects(c *gin.Context, clusterID string) { namespaces, err := r.clusterService.ListNamespaces(clusterID) if err != nil { log.Error("%+v", clusterID, err) - c.JSON(err.Status, err) + c.AbortWithStatusJSON(err.Status, err) return } @@ -55,7 +55,7 @@ func (r IProjectController) GetProject(c *gin.Context, clusterID string, project ns, err := r.clusterService.GetNamespaceByName(clusterID, projectName) if err != nil { log.Error("%+v", clusterID, err) - c.JSON(err.Status, err) + c.AbortWithStatusJSON(err.Status, err) return } c.JSON(http.StatusOK, ns.ToProject()) @@ -65,7 +65,7 @@ func (r IProjectController) CreateProject(c *gin.Context, clusterID string) { var project model.Project if err := c.ShouldBindJSON(&project); err != nil { resp := model.NewServerResponse(model.OkdpServerResponse).BadRequest("%+v", err.Error()) - c.JSON(resp.Status, resp) + c.AbortWithStatusJSON(resp.Status, resp) return } response := r.clusterService.CreateNamespace(clusterID, project.ToNamespace()) @@ -77,7 +77,7 @@ func (r IProjectController) UpdateProject(c *gin.Context, clusterID string) { var project model.Project if err := c.ShouldBindJSON(&project); err != nil { resp := model.NewServerResponse(model.OkdpServerResponse).BadRequest("%+v", err.Error()) - c.JSON(resp.Status, resp) + c.AbortWithStatusJSON(resp.Status, resp) return } response := r.clusterService.UpdateNamespace(clusterID, project.ToNamespace()) diff --git a/internal/integrations/k8s/client/fluxcd.go b/internal/integrations/k8s/client/fluxcd.go index ae660b7..86bbf1d 100644 --- a/internal/integrations/k8s/client/fluxcd.go +++ b/internal/integrations/k8s/client/fluxcd.go @@ -22,7 +22,7 @@ import ( kustomizev1 "github.com/fluxcd/kustomize-controller/api/v1" sourcev1 "github.com/fluxcd/source-controller/api/v1" sourcev1b2 "github.com/fluxcd/source-controller/api/v1beta2" - k8s "sigs.k8s.io/controller-runtime/pkg/client" + ctrlclient "sigs.k8s.io/controller-runtime/pkg/client" "github.com/okdp/okdp-server/internal/model" "github.com/okdp/okdp-server/internal/utils" @@ -61,7 +61,7 @@ func (c KubeClient) ListGitRepositories(ctx context.Context, namespaces ...strin } func (c KubeClient) GetGitRepository(ctx context.Context, name string, namespace string) (*sourcev1.GitRepository, *model.ServerResponse) { - repoKey := k8s.ObjectKey{ + repoKey := ctrlclient.ObjectKey{ Namespace: namespace, Name: name, } @@ -93,7 +93,7 @@ func (c KubeClient) ListOCIRepositories(ctx context.Context, namespaces ...strin } func (c KubeClient) GetOCIRepository(ctx context.Context, name string, namespace string) (*sourcev1b2.OCIRepository, *model.ServerResponse) { - repoKey := k8s.ObjectKey{ + repoKey := ctrlclient.ObjectKey{ Namespace: namespace, Name: name, } diff --git a/internal/integrations/k8s/client/secret.go b/internal/integrations/k8s/client/secret.go index 4cc8688..1060956 100644 --- a/internal/integrations/k8s/client/secret.go +++ b/internal/integrations/k8s/client/secret.go @@ -26,7 +26,7 @@ import ( "github.com/okdp/okdp-server/internal/model" "github.com/skeema/knownhosts" corev1 "k8s.io/api/core/v1" - k8s "sigs.k8s.io/controller-runtime/pkg/client" + ctrlclient "sigs.k8s.io/controller-runtime/pkg/client" log "github.com/okdp/okdp-server/internal/common/logging" ) @@ -36,7 +36,7 @@ type K8SSecret struct { } func (c KubeClient) GetSecret(ctx context.Context, name string, namespace string) (*K8SSecret, *model.ServerResponse) { - secretKey := k8s.ObjectKey{ + secretKey := ctrlclient.ObjectKey{ Namespace: namespace, Name: name, } diff --git a/internal/utils/k8s_utils.go b/internal/utils/k8s_utils.go index 701c6c7..37ef6e0 100644 --- a/internal/utils/k8s_utils.go +++ b/internal/utils/k8s_utils.go @@ -17,9 +17,16 @@ package utils import ( + "github.com/okdp/okdp-server/internal/common/constants" corev1 "k8s.io/api/core/v1" ) +type ContainerStateInfo struct { + State string `json:"state"` + Reason string `json:"reason,omitempty"` + Message string `json:"message,omitempty"` +} + // MergeLabels merges new labels into an existing Namespace's labels. // It adds new keys and updates the values of existing keys. func MergeLabels(ns *corev1.Namespace, newLabels *map[string]string) { @@ -47,3 +54,71 @@ func MergeAnnotations(ns *corev1.Namespace, newAnnotations *map[string]string) { ns.Annotations[k] = v // Overwrite or add } } + +// GetPodHealth returns a high-level health status string for a given Kubernetes Pod. +// - If the Pod phase is "Running" and all containers are ready, returns "Healthy". +// - If the Pod is "Running" but not all containers are ready, returns "NotReady". +// - For other phases, returns "Pending", "Completed", or "Failed" accordingly. +// - If the phase is unknown, returns "Unknown". +func GetPodHealth(pod *corev1.Pod) string { + switch pod.Status.Phase { + case corev1.PodRunning: + if AreAllContainersReady(pod) { + return constants.StateHealthy + } + return constants.StateNotReady + case corev1.PodPending: + return constants.StatePending + case corev1.PodSucceeded: + return constants.StateCompleted + case corev1.PodFailed: + return constants.StateFailed + default: + return constants.StateUnknown + } +} + +// AreAllContainersReady returns true if all containers in the pod have their Ready status set to true. +// Otherwise, it returns false. +func AreAllContainersReady(pod *corev1.Pod) bool { + for _, cs := range pod.Status.ContainerStatuses { + if !cs.Ready { + return false + } + } + return true +} + +// GetContainerState returns the high-level state, reason, and message of a container in a given pod. +// - State is one of "Running", "Waiting", "Terminated", or "Unknown" (exported constant recommended). +// - Reason and Message are only set for Waiting or Terminated states, otherwise empty strings. +// If the container is not found, returns "Unknown" state and empty reason/message. +func GetContainerState(pod *corev1.Pod, containerName string) ContainerStateInfo { + for _, status := range pod.Status.ContainerStatuses { + if status.Name == containerName { + if status.State.Running != nil { + return ContainerStateInfo{ + State: constants.StateRunning, + Reason: "", + Message: "", + } + } + if status.State.Waiting != nil { + return ContainerStateInfo{ + State: constants.StateWaiting, + Reason: status.State.Waiting.Reason, + Message: status.State.Waiting.Message, + } + } + if status.State.Terminated != nil { + return ContainerStateInfo{ + State: constants.StateTerminated, + Reason: status.State.Terminated.Reason, + Message: status.State.Terminated.Message, + } + } + return ContainerStateInfo{State: constants.StateUnknown} + } + } + return ContainerStateInfo{State: constants.StateUnknown} +} diff --git a/internal/utils/parameter_utils.go b/internal/utils/parameter_utils.go index 39b6a4a..355d140 100644 --- a/internal/utils/parameter_utils.go +++ b/internal/utils/parameter_utils.go @@ -37,3 +37,22 @@ func DefaultIfEmpty(value, defaultValue string) string { } return defaultValue } + +// EmptyToNil returns a pointer to the given string, unless the string is empty. +// If the input string is "", it returns nil. Otherwise, it returns a pointer to the string. +// This is useful when you want to omit empty fields in JSON serialization with the 'omitempty' tag. +func EmptyToNil(s string) *string { + if s == "" { + return nil + } + return &s +} + +// NilToEmptySlice returns the input slice if not nil, or an empty slice if input is nil. +// Useful to ensure you never return a nil slice (for API responses, etc). +func NilToEmptySlice[T any](s []T) []T { + if s == nil { + return []T{} + } + return s +} From 5ac901d883330af8f86f7964ebbc7a5c40f1809e Mon Sep 17 00:00:00 2001 From: iizitounene Date: Tue, 19 Aug 2025 17:38:29 +0200 Subject: [PATCH 5/7] chore: update version to 0.4.0 --- helm/okdp-server/Chart.yaml | 4 ++-- helm/okdp-server/values.yaml | 2 +- package.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/helm/okdp-server/Chart.yaml b/helm/okdp-server/Chart.yaml index e3ba240..c842048 100644 --- a/helm/okdp-server/Chart.yaml +++ b/helm/okdp-server/Chart.yaml @@ -2,8 +2,8 @@ apiVersion: v2 name: okdp-server description: A Helm chart for okdp-server type: application -version: 0.3.0 -appVersion: 0.3.0 +version: 0.4.0 +appVersion: 0.4.0 home: https://okdp.io maintainers: - email: idir.izitounene@kubotal.io diff --git a/helm/okdp-server/values.yaml b/helm/okdp-server/values.yaml index 32438a9..9cf4a94 100644 --- a/helm/okdp-server/values.yaml +++ b/helm/okdp-server/values.yaml @@ -7,7 +7,7 @@ image: # -- Image pull policy. pullPolicy: Always # -- Image tag. - tag: "0.3.0" + tag: "0.4.0" # -- Secrets to be used for pulling images from private Docker registries. imagePullSecrets: [] diff --git a/package.json b/package.json index 977c01e..45462e7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "okdp-server", - "version": "0.3.0", + "version": "0.4.0", "description": "okdp-server docker image", "repository": { "type": "git", From 87020427d0994a238c6f61d1762c5176a5925df5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 19 Aug 2025 15:40:28 +0000 Subject: [PATCH 6/7] [helm-docs] Update readme --- helm/okdp-server/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/helm/okdp-server/README.md b/helm/okdp-server/README.md index 0cf54d5..a012955 100644 --- a/helm/okdp-server/README.md +++ b/helm/okdp-server/README.md @@ -1,6 +1,6 @@ # okdp-server -![Version: 0.3.0](https://img.shields.io/badge/Version-0.3.0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 0.3.0](https://img.shields.io/badge/AppVersion-0.3.0-informational?style=flat-square) +![Version: 0.4.0](https://img.shields.io/badge/Version-0.4.0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 0.4.0](https://img.shields.io/badge/AppVersion-0.4.0-informational?style=flat-square) A Helm chart for okdp-server @@ -80,7 +80,7 @@ A Helm chart for okdp-server | fullnameOverride | string | `""` | Overrides the release name. | | image.pullPolicy | string | `"Always"` | Image pull policy. | | image.repository | string | `"quay.io/okdp/okdp-server"` | Docker image registry. | -| image.tag | string | `"0.3.0"` | Image tag. | +| image.tag | string | `"0.4.0"` | Image tag. | | imagePullSecrets | list | `[]` | Secrets to be used for pulling images from private Docker registries. | | ingress.annotations | object | `{}` | | | ingress.className | string | `""` | Specify the ingress class (Kubernetes >= 1.18). | From c9eef58da6663e2e21cbfeb98deb8493a4573b64 Mon Sep 17 00:00:00 2001 From: iizitounene Date: Tue, 19 Aug 2025 17:59:57 +0200 Subject: [PATCH 7/7] feat: list okdp specific projects only --- internal/integrations/k8s/client/k8s.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/internal/integrations/k8s/client/k8s.go b/internal/integrations/k8s/client/k8s.go index 1350e6c..9a6ad90 100644 --- a/internal/integrations/k8s/client/k8s.go +++ b/internal/integrations/k8s/client/k8s.go @@ -44,9 +44,16 @@ func (c KubeClient) ListNamespaces(ctx context.Context) ([]*model.Namespace, *mo namespaces := []*model.Namespace{} for _, ns := range namespaceList.Items { + // exclude unwanted namespaces if exclude[ns.Name] || strings.HasPrefix(ns.Name, "kube-") { continue } + + // include only okdp projects: namespaces with label okdp.io/project=true + default + if ns.Name != "default" && (ns.Labels["okdp.io/project"] != "true") { + continue + } + namespaces = append(namespaces, model.ToNamespace(ns)) }