Skip to content

Commit 0b7ddf5

Browse files
authored
feat: new job analyzer (#1506)
Signed-off-by: Kay Yan <kay.yan@daocloud.io>
1 parent d0f0364 commit 0b7ddf5

4 files changed

Lines changed: 324 additions & 0 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,7 @@ you will be able to write your own analyzers.
252252
- [x] ingressAnalyzer
253253
- [x] statefulSetAnalyzer
254254
- [x] deploymentAnalyzer
255+
- [x] jobAnalyzer
255256
- [x] cronJobAnalyzer
256257
- [x] nodeAnalyzer
257258
- [x] mutatingWebhookAnalyzer

pkg/analyzer/analyzer.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ var coreAnalyzerMap = map[string]common.IAnalyzer{
3939
"Service": ServiceAnalyzer{},
4040
"Ingress": IngressAnalyzer{},
4141
"StatefulSet": StatefulSetAnalyzer{},
42+
"Job": JobAnalyzer{},
4243
"CronJob": CronJobAnalyzer{},
4344
"Node": NodeAnalyzer{},
4445
"ValidatingWebhookConfiguration": ValidatingWebhookAnalyzer{},

pkg/analyzer/job.go

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
/*
2+
Copyright 2025 The K8sGPT Authors.
3+
Licensed under the Apache License, Version 2.0 (the "License");
4+
you may not use this file except in compliance with the License.
5+
You may obtain a copy of the License at
6+
http://www.apache.org/licenses/LICENSE-2.0
7+
Unless required by applicable law or agreed to in writing, software
8+
distributed under the License is distributed on an "AS IS" BASIS,
9+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
10+
See the License for the specific language governing permissions and
11+
limitations under the License.
12+
*/
13+
14+
package analyzer
15+
16+
import (
17+
"fmt"
18+
19+
"github.com/k8sgpt-ai/k8sgpt/pkg/common"
20+
"github.com/k8sgpt-ai/k8sgpt/pkg/kubernetes"
21+
"github.com/k8sgpt-ai/k8sgpt/pkg/util"
22+
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
23+
"k8s.io/apimachinery/pkg/runtime/schema"
24+
)
25+
26+
type JobAnalyzer struct{}
27+
28+
func (analyzer JobAnalyzer) Analyze(a common.Analyzer) ([]common.Result, error) {
29+
30+
kind := "Job"
31+
apiDoc := kubernetes.K8sApiReference{
32+
Kind: kind,
33+
ApiVersion: schema.GroupVersion{
34+
Group: "batch",
35+
Version: "v1",
36+
},
37+
OpenapiSchema: a.OpenapiSchema,
38+
}
39+
40+
AnalyzerErrorsMetric.DeletePartialMatch(map[string]string{
41+
"analyzer_name": kind,
42+
})
43+
44+
JobList, err := a.Client.GetClient().BatchV1().Jobs(a.Namespace).List(a.Context, v1.ListOptions{LabelSelector: a.LabelSelector})
45+
if err != nil {
46+
return nil, err
47+
}
48+
49+
var preAnalysis = map[string]common.PreAnalysis{}
50+
51+
for _, Job := range JobList.Items {
52+
var failures []common.Failure
53+
if Job.Spec.Suspend != nil && *Job.Spec.Suspend {
54+
doc := apiDoc.GetApiDocV2("spec.suspend")
55+
56+
failures = append(failures, common.Failure{
57+
Text: fmt.Sprintf("Job %s is suspended", Job.Name),
58+
KubernetesDoc: doc,
59+
Sensitive: []common.Sensitive{
60+
{
61+
Unmasked: Job.Namespace,
62+
Masked: util.MaskString(Job.Namespace),
63+
},
64+
{
65+
Unmasked: Job.Name,
66+
Masked: util.MaskString(Job.Name),
67+
},
68+
},
69+
})
70+
}
71+
if Job.Status.Failed > 0 {
72+
doc := apiDoc.GetApiDocV2("status.failed")
73+
failures = append(failures, common.Failure{
74+
Text: fmt.Sprintf("Job %s has failed", Job.Name),
75+
KubernetesDoc: doc,
76+
Sensitive: []common.Sensitive{
77+
{
78+
Unmasked: Job.Namespace,
79+
Masked: util.MaskString(Job.Namespace),
80+
},
81+
{
82+
Unmasked: Job.Name,
83+
Masked: util.MaskString(Job.Name),
84+
},
85+
},
86+
})
87+
}
88+
89+
if len(failures) > 0 {
90+
preAnalysis[fmt.Sprintf("%s/%s", Job.Namespace, Job.Name)] = common.PreAnalysis{
91+
FailureDetails: failures,
92+
}
93+
AnalyzerErrorsMetric.WithLabelValues(kind, Job.Name, Job.Namespace).Set(float64(len(failures)))
94+
}
95+
}
96+
97+
for key, value := range preAnalysis {
98+
currentAnalysis := common.Result{
99+
Kind: kind,
100+
Name: key,
101+
Error: value.FailureDetails,
102+
}
103+
a.Results = append(a.Results, currentAnalysis)
104+
}
105+
106+
return a.Results, nil
107+
}

pkg/analyzer/job_test.go

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
/*
2+
Copyright 2025 The K8sGPT Authors.
3+
Licensed under the Apache License, Version 2.0 (the "License");
4+
you may not use this file except in compliance with the License.
5+
You may obtain a copy of the License at
6+
http://www.apache.org/licenses/LICENSE-2.0
7+
Unless required by applicable law or agreed to in writing, software
8+
distributed under the License is distributed on an "AS IS" BASIS,
9+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
10+
See the License for the specific language governing permissions and
11+
limitations under the License.
12+
*/
13+
14+
package analyzer
15+
16+
import (
17+
"context"
18+
"sort"
19+
"testing"
20+
21+
"github.com/k8sgpt-ai/k8sgpt/pkg/common"
22+
"github.com/k8sgpt-ai/k8sgpt/pkg/kubernetes"
23+
"github.com/stretchr/testify/require"
24+
batchv1 "k8s.io/api/batch/v1"
25+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
26+
"k8s.io/client-go/kubernetes/fake"
27+
)
28+
29+
func TestJobAnalyzer(t *testing.T) {
30+
tests := []struct {
31+
name string
32+
config common.Analyzer
33+
expectations []struct {
34+
name string
35+
failuresCount int
36+
}
37+
}{
38+
{
39+
name: "Suspended Job",
40+
config: common.Analyzer{
41+
Client: &kubernetes.Client{
42+
Client: fake.NewSimpleClientset(
43+
&batchv1.Job{
44+
ObjectMeta: metav1.ObjectMeta{
45+
Name: "suspended-job",
46+
Namespace: "default",
47+
},
48+
Spec: batchv1.JobSpec{
49+
Suspend: boolPtr(true),
50+
},
51+
},
52+
),
53+
},
54+
Context: context.Background(),
55+
Namespace: "default",
56+
},
57+
expectations: []struct {
58+
name string
59+
failuresCount int
60+
}{
61+
{
62+
name: "default/suspended-job",
63+
failuresCount: 1, // One failure for being suspended
64+
},
65+
},
66+
},
67+
68+
{
69+
name: "Failed Job",
70+
config: common.Analyzer{
71+
Client: &kubernetes.Client{
72+
Client: fake.NewSimpleClientset(
73+
&batchv1.Job{
74+
ObjectMeta: metav1.ObjectMeta{
75+
Name: "failed-job",
76+
Namespace: "default",
77+
},
78+
Spec: batchv1.JobSpec{},
79+
Status: batchv1.JobStatus{
80+
Failed: 1,
81+
},
82+
},
83+
),
84+
},
85+
Context: context.Background(),
86+
Namespace: "default",
87+
},
88+
expectations: []struct {
89+
name string
90+
failuresCount int
91+
}{
92+
{
93+
name: "default/failed-job",
94+
failuresCount: 1, // One failure for failed job
95+
},
96+
},
97+
},
98+
{
99+
name: "Valid Job",
100+
config: common.Analyzer{
101+
Client: &kubernetes.Client{
102+
Client: fake.NewSimpleClientset(
103+
&batchv1.Job{
104+
ObjectMeta: metav1.ObjectMeta{
105+
Name: "valid-job",
106+
Namespace: "default",
107+
},
108+
Spec: batchv1.JobSpec{},
109+
},
110+
),
111+
},
112+
Context: context.Background(),
113+
Namespace: "default",
114+
},
115+
expectations: []struct {
116+
name string
117+
failuresCount int
118+
}{
119+
// No expectations for valid job
120+
},
121+
},
122+
{
123+
name: "Multiple issues",
124+
config: common.Analyzer{
125+
Client: &kubernetes.Client{
126+
Client: fake.NewSimpleClientset(
127+
&batchv1.Job{
128+
ObjectMeta: metav1.ObjectMeta{
129+
Name: "multiple-issues",
130+
Namespace: "default",
131+
},
132+
Spec: batchv1.JobSpec{
133+
Suspend: boolPtr(true),
134+
},
135+
Status: batchv1.JobStatus{
136+
Failed: 1,
137+
},
138+
},
139+
),
140+
},
141+
Context: context.Background(),
142+
Namespace: "default",
143+
},
144+
expectations: []struct {
145+
name string
146+
failuresCount int
147+
}{
148+
{
149+
name: "default/multiple-issues",
150+
failuresCount: 2, // Two failures: suspended and failed job
151+
},
152+
},
153+
},
154+
}
155+
156+
for _, tt := range tests {
157+
t.Run(tt.name, func(t *testing.T) {
158+
analyzer := JobAnalyzer{}
159+
results, err := analyzer.Analyze(tt.config)
160+
require.NoError(t, err)
161+
require.Len(t, results, len(tt.expectations))
162+
163+
// Sort results by name for consistent comparison
164+
sort.Slice(results, func(i, j int) bool {
165+
return results[i].Name < results[j].Name
166+
})
167+
168+
for i, expectation := range tt.expectations {
169+
require.Equal(t, expectation.name, results[i].Name)
170+
require.Len(t, results[i].Error, expectation.failuresCount)
171+
}
172+
})
173+
}
174+
}
175+
176+
func TestJobAnalyzerLabelSelector(t *testing.T) {
177+
clientSet := fake.NewSimpleClientset(
178+
&batchv1.Job{
179+
ObjectMeta: metav1.ObjectMeta{
180+
Name: "job-with-label",
181+
Namespace: "default",
182+
Labels: map[string]string{
183+
"app": "test",
184+
},
185+
},
186+
Spec: batchv1.JobSpec{},
187+
Status: batchv1.JobStatus{
188+
Failed: 1,
189+
},
190+
},
191+
&batchv1.Job{
192+
ObjectMeta: metav1.ObjectMeta{
193+
Name: "job-without-label",
194+
Namespace: "default",
195+
},
196+
Spec: batchv1.JobSpec{},
197+
},
198+
)
199+
200+
// Test with label selector
201+
config := common.Analyzer{
202+
Client: &kubernetes.Client{
203+
Client: clientSet,
204+
},
205+
Context: context.Background(),
206+
Namespace: "default",
207+
LabelSelector: "app=test",
208+
}
209+
210+
analyzer := JobAnalyzer{}
211+
results, err := analyzer.Analyze(config)
212+
require.NoError(t, err)
213+
require.Equal(t, 1, len(results))
214+
require.Equal(t, "default/job-with-label", results[0].Name)
215+
}

0 commit comments

Comments
 (0)