From 8b23492cb90397ad58a2039038486475e14322fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Sch=C3=BCnemann?= Date: Wed, 7 May 2025 13:21:11 +0200 Subject: [PATCH] add common kubernetes readiness checks --- README.md | 27 +++ go.mod | 5 +- go.sum | 10 +- pkg/readiness/readinesscheck.go | 101 +++++++++++ pkg/readiness/readinesscheck_test.go | 245 +++++++++++++++++++++++++++ 5 files changed, 382 insertions(+), 6 deletions(-) create mode 100644 pkg/readiness/readinesscheck.go create mode 100644 pkg/readiness/readinesscheck_test.go diff --git a/README.md b/README.md index cdeb7d3..8782c3f 100644 --- a/README.md +++ b/README.md @@ -298,6 +298,33 @@ env := testing.NewEnvironmentBuilder(). env.ShouldReconcile(testing.RequestFromStrings("testresource")) ``` +### Readiness Checks + +The `pkg/readiness` package provides a simple way to check if a kubernetes resource is ready. +The meaning of readiness depends on the resource type. + +#### Example + +```go +deployment := &appsv1.Deployment{} +err := r.Client.Get(ctx, types.NamespacedName{ + Name: "my-deployment", + Namespace: "my-namespace", +}, deployment) + +if err != nil { + return err +} + +readiness := readiness.CheckDeployment(deployment) + +if readiness.IsReady() { + fmt.Println("Deployment is ready") +} else { + fmt.Printf("Deployment is not ready: %s\n", readiness.Message()) +} +``` + ## Support, Feedback, Contributing This project is open to feature requests/suggestions, bug reports etc. via [GitHub issues](https://github.com/openmcp-project/controller-utils/issues). Contribution and feedback are encouraged and always welcome. For more information about how to contribute, the project structure, as well as additional contribution information, see our [Contribution Guidelines](CONTRIBUTING.md). diff --git a/go.mod b/go.mod index 13e2acb..c7a90aa 100644 --- a/go.mod +++ b/go.mod @@ -19,6 +19,7 @@ require ( k8s.io/client-go v0.33.0 k8s.io/utils v0.0.0-20250502105355-0f33e8f1c979 sigs.k8s.io/controller-runtime v0.20.4 + sigs.k8s.io/gateway-api v1.3.0 sigs.k8s.io/yaml v1.4.0 ) @@ -66,7 +67,7 @@ require ( golang.org/x/time v0.10.0 // indirect golang.org/x/tools v0.33.0 // indirect gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect - google.golang.org/protobuf v1.36.5 // 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/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect @@ -74,5 +75,5 @@ require ( k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff // indirect sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect sigs.k8s.io/randfill v1.0.0 // indirect - sigs.k8s.io/structured-merge-diff/v4 v4.6.0 // indirect + sigs.k8s.io/structured-merge-diff/v4 v4.7.0 // indirect ) diff --git a/go.sum b/go.sum index 9494bd9..ccd3ff6 100644 --- a/go.sum +++ b/go.sum @@ -193,8 +193,8 @@ google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQ google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= -google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= +google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= @@ -226,12 +226,14 @@ k8s.io/utils v0.0.0-20250502105355-0f33e8f1c979 h1:jgJW5IePPXLGB8e/1wvd0Ich9QE97 k8s.io/utils v0.0.0-20250502105355-0f33e8f1c979/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= sigs.k8s.io/controller-runtime v0.20.4 h1:X3c+Odnxz+iPTRobG4tp092+CvBU9UK0t/bRf+n0DGU= sigs.k8s.io/controller-runtime v0.20.4/go.mod h1:xg2XB0K5ShQzAgsoujxuKN4LNXR2LfwwHsPj7Iaw+XY= +sigs.k8s.io/gateway-api v1.3.0 h1:q6okN+/UKDATola4JY7zXzx40WO4VISk7i9DIfOvr9M= +sigs.k8s.io/gateway-api v1.3.0/go.mod h1:d8NV8nJbaRbEKem+5IuxkL8gJGOZ+FJ+NvOIltV8gDk= sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE= sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/randfill v0.0.0-20250304075658-069ef1bbf016/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= -sigs.k8s.io/structured-merge-diff/v4 v4.6.0 h1:IUA9nvMmnKWcj5jl84xn+T5MnlZKThmUW1TdblaLVAc= -sigs.k8s.io/structured-merge-diff/v4 v4.6.0/go.mod h1:dDy58f92j70zLsuZVuUX5Wp9vtxXpaZnkPGWeqDfCps= +sigs.k8s.io/structured-merge-diff/v4 v4.7.0 h1:qPeWmscJcXP0snki5IYF79Z8xrl8ETFxgMd7wez1XkI= +sigs.k8s.io/structured-merge-diff/v4 v4.7.0/go.mod h1:dDy58f92j70zLsuZVuUX5Wp9vtxXpaZnkPGWeqDfCps= sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= diff --git a/pkg/readiness/readinesscheck.go b/pkg/readiness/readinesscheck.go new file mode 100644 index 0000000..4bcaac7 --- /dev/null +++ b/pkg/readiness/readinesscheck.go @@ -0,0 +1,101 @@ +package readiness + +import ( + "fmt" + "slices" + "strings" + + appsv1 "k8s.io/api/apps/v1" + batchv1 "k8s.io/api/batch/v1" + networkingv1 "k8s.io/api/networking/v1" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" +) + +type CheckResult []string + +func (r CheckResult) IsReady() bool { + return len(r) == 0 +} + +func (r CheckResult) Message() string { + return strings.Join(r, ", ") +} + +func NewReadyResult() CheckResult { + return CheckResult{} +} + +func NewNotReadyResult(message string) CheckResult { + return CheckResult{message} +} + +func NewFailedResult(err error) CheckResult { + return NewNotReadyResult(fmt.Sprintf("readiness check failed: %v", err)) +} + +func Aggregate(results ...CheckResult) CheckResult { + return slices.Concat(results...) +} + +// CheckDeployment checks the readiness of a deployment. +func CheckDeployment(dp *appsv1.Deployment) CheckResult { + if dp.Status.ObservedGeneration < dp.Generation { + return NewNotReadyResult(fmt.Sprintf("deployment %s/%s not ready: observed generation outdated", dp.Namespace, dp.Name)) + } + + var specReplicas int32 = 0 + if dp.Spec.Replicas != nil { + specReplicas = *dp.Spec.Replicas + } + + if dp.Generation != dp.Status.ObservedGeneration || + specReplicas != dp.Status.Replicas || + specReplicas != dp.Status.UpdatedReplicas || + specReplicas != dp.Status.AvailableReplicas { + return NewNotReadyResult(fmt.Sprintf("deployment %s/%s is not ready", dp.Namespace, dp.Name)) + } + + return NewReadyResult() +} + +// CheckJob checks the completion status of a Kubernetes Job. +func CheckJob(job *batchv1.Job) CheckResult { + for _, condition := range job.Status.Conditions { + if condition.Type == batchv1.JobComplete && condition.Status == "True" { + return NewReadyResult() + } + if condition.Type == batchv1.JobFailed && condition.Status == "True" { + return NewNotReadyResult(fmt.Sprintf("job %s/%s failed: %s", job.Namespace, job.Name, condition.Message)) + } + } + + return NewNotReadyResult(fmt.Sprintf("job %s/%s is not completed", job.Namespace, job.Name)) +} + +// CheckJobFailed checks if a Kubernetes Job has failed. +func CheckJobFailed(job *batchv1.Job) bool { + for _, condition := range job.Status.Conditions { + if condition.Type == batchv1.JobFailed && condition.Status == "True" { + return true + } + } + return false +} + +// CheckIngress checks the readiness of a Kubernetes Ingress. +func CheckIngress(ingress *networkingv1.Ingress) CheckResult { + if len(ingress.Status.LoadBalancer.Ingress) > 0 { + return NewReadyResult() + } + return NewNotReadyResult(fmt.Sprintf("ingress %s/%s is not ready: no load balancer ingress entries", ingress.Namespace, ingress.Name)) +} + +// CheckGateway checks the readiness of a Kubernetes Gateway. +func CheckGateway(gateway *gatewayv1.Gateway) CheckResult { + for _, condition := range gateway.Status.Conditions { + if condition.Type == string(gatewayv1.GatewayConditionReady) && condition.Status == "True" { + return NewReadyResult() + } + } + return NewNotReadyResult(fmt.Sprintf("gateway %s/%s is not ready: no ready condition with status True", gateway.Namespace, gateway.Name)) +} diff --git a/pkg/readiness/readinesscheck_test.go b/pkg/readiness/readinesscheck_test.go new file mode 100644 index 0000000..c12c598 --- /dev/null +++ b/pkg/readiness/readinesscheck_test.go @@ -0,0 +1,245 @@ +package readiness_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + appsv1 "k8s.io/api/apps/v1" + batchv1 "k8s.io/api/batch/v1" + networkingv1 "k8s.io/api/networking/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/ptr" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" + + "github.com/openmcp-project/controller-utils/pkg/readiness" +) + +func TestConfig(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Readiness Check Test Suite") +} + +var _ = Describe("Readiness Check", func() { + + It("should return true when the readiness check is ready", func() { + result := readiness.NewReadyResult() + Expect(result.IsReady()).To(BeTrue()) + }) + + It("should return false when the readiness check is not ready", func() { + result := readiness.NewNotReadyResult("test message") + Expect(result.IsReady()).To(BeFalse()) + }) + + It("should return false when the readiness check is failed", func() { + result := readiness.NewFailedResult(nil) + Expect(result.IsReady()).To(BeFalse()) + }) + + It("should return the message", func() { + result := readiness.NewNotReadyResult("test message") + Expect(result.Message()).To(Equal("test message")) + }) + + It("should return the message with multiple messages", func() { + result := readiness.Aggregate( + readiness.NewNotReadyResult("test message 1"), + readiness.NewNotReadyResult("test message 2"), + ) + Expect(result.Message()).To(Equal("test message 1, test message 2")) + }) + + It("should return true when a deployment is ready", func() { + deployment := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Generation: 1, + }, + Spec: appsv1.DeploymentSpec{ + Replicas: ptr.To[int32](1), + }, + Status: appsv1.DeploymentStatus{ + ObservedGeneration: 1, + Replicas: 1, + UpdatedReplicas: 1, + AvailableReplicas: 1, + }, + } + result := readiness.CheckDeployment(deployment) + Expect(result.IsReady()).To(BeTrue()) + }) + + It("should return false when a deployment is not ready", func() { + deployment := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Generation: 1, + }, + Spec: appsv1.DeploymentSpec{ + Replicas: ptr.To[int32](1), + }, + Status: appsv1.DeploymentStatus{ + ObservedGeneration: 1, + Replicas: 1, + UpdatedReplicas: 0, + AvailableReplicas: 0, + }, + } + result := readiness.CheckDeployment(deployment) + Expect(result.IsReady()).To(BeFalse()) + }) + + It("should return true when a job is completed", func() { + job := &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "default", + Name: "test-job", + }, + Status: batchv1.JobStatus{ + Conditions: []batchv1.JobCondition{ + { + Type: batchv1.JobComplete, + Status: "True", + }, + }, + }, + } + result := readiness.CheckJob(job) + Expect(result.IsReady()).To(BeTrue()) + }) + + It("should return false when a job has failed", func() { + job := &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "default", + Name: "test-job", + }, + Status: batchv1.JobStatus{ + Conditions: []batchv1.JobCondition{ + { + Type: batchv1.JobFailed, + Status: "True", + Message: "Job failed due to an error", + }, + }, + }, + } + result := readiness.CheckJob(job) + Expect(result.IsReady()).To(BeFalse()) + Expect(result.Message()).To(Equal("job default/test-job failed: Job failed due to an error")) + }) + + It("should return false when a job is not completed", func() { + job := &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "default", + Name: "test-job", + }, + Status: batchv1.JobStatus{ + Conditions: []batchv1.JobCondition{}, + }, + } + result := readiness.CheckJob(job) + Expect(result.IsReady()).To(BeFalse()) + Expect(result.Message()).To(Equal("job default/test-job is not completed")) + }) + + It("should return true when a job has failed using CheckJobFailed", func() { + job := &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "default", + Name: "test-job", + }, + Status: batchv1.JobStatus{ + Conditions: []batchv1.JobCondition{ + { + Type: batchv1.JobFailed, + Status: "True", + }, + }, + }, + } + Expect(readiness.CheckJobFailed(job)).To(BeTrue()) + }) + + It("should return false when a job has not failed using CheckJobFailed", func() { + job := &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "default", + Name: "test-job", + }, + Status: batchv1.JobStatus{ + Conditions: []batchv1.JobCondition{}, + }, + } + Expect(readiness.CheckJobFailed(job)).To(BeFalse()) + }) + + It("should return true when an ingress is ready", func() { + ingress := &networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "default", + Name: "test-ingress", + }, + Status: networkingv1.IngressStatus{ + LoadBalancer: networkingv1.IngressLoadBalancerStatus{ + Ingress: []networkingv1.IngressLoadBalancerIngress{ + {Hostname: "example.com"}, + }, + }, + }, + } + result := readiness.CheckIngress(ingress) + Expect(result.IsReady()).To(BeTrue()) + }) + + It("should return false when an ingress is not ready", func() { + ingress := &networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "default", + Name: "test-ingress", + }, + Status: networkingv1.IngressStatus{ + LoadBalancer: networkingv1.IngressLoadBalancerStatus{ + Ingress: []networkingv1.IngressLoadBalancerIngress{}, + }, + }, + } + result := readiness.CheckIngress(ingress) + Expect(result.IsReady()).To(BeFalse()) + Expect(result.Message()).To(Equal("ingress default/test-ingress is not ready: no load balancer ingress entries")) + }) + + It("should return true when a gateway is ready", func() { + gateway := &gatewayv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "default", + Name: "test-gateway", + }, + Status: gatewayv1.GatewayStatus{ + Conditions: []metav1.Condition{ + { + Type: string(gatewayv1.GatewayConditionReady), + Status: "True", + }, + }, + }, + } + result := readiness.CheckGateway(gateway) + Expect(result.IsReady()).To(BeTrue()) + }) + + It("should return false when a gateway is not ready", func() { + gateway := &gatewayv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "default", + Name: "test-gateway", + }, + Status: gatewayv1.GatewayStatus{ + Conditions: []metav1.Condition{}, + }, + } + result := readiness.CheckGateway(gateway) + Expect(result.IsReady()).To(BeFalse()) + Expect(result.Message()).To(Equal("gateway default/test-gateway is not ready: no ready condition with status True")) + }) +})