Skip to content

Commit 07eb2c8

Browse files
authored
Implement unit tests for API server and controller components (#86)
1 parent 7df640a commit 07eb2c8

17 files changed

Lines changed: 2331 additions & 7 deletions
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package apiserver
2+
3+
import (
4+
"encoding/json"
5+
"net/http"
6+
"net/http/httptest"
7+
"testing"
8+
9+
"github.com/gnmic/operator/internal/controller"
10+
"github.com/gnmic/operator/internal/gnmic"
11+
gapi "github.com/openconfig/gnmic/pkg/api/types"
12+
)
13+
14+
func TestGetClusterPlan(t *testing.T) {
15+
plan := &gnmic.ApplyPlan{
16+
Targets: map[string]*gapi.TargetConfig{
17+
"default/t1": {Name: "default/t1"},
18+
},
19+
}
20+
reconciler := controller.NewClusterReconcilerForTest()
21+
reconciler.CachePlan("default", "cluster-a", plan)
22+
23+
srv := New(":0", reconciler)
24+
ts := httptest.NewServer(srv.Server.Handler)
25+
defer ts.Close()
26+
27+
t.Run("found", func(t *testing.T) {
28+
resp, err := http.Get(ts.URL + "/clusters/default/cluster-a/plan")
29+
if err != nil {
30+
t.Fatal(err)
31+
}
32+
defer resp.Body.Close()
33+
if resp.StatusCode != http.StatusOK {
34+
t.Fatalf("status = %d, want 200", resp.StatusCode)
35+
}
36+
var got gnmic.ApplyPlan
37+
if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
38+
t.Fatal(err)
39+
}
40+
if len(got.Targets) != 1 {
41+
t.Fatalf("targets = %d, want 1", len(got.Targets))
42+
}
43+
})
44+
45+
t.Run("not found", func(t *testing.T) {
46+
resp, err := http.Get(ts.URL + "/clusters/default/missing/plan")
47+
if err != nil {
48+
t.Fatal(err)
49+
}
50+
defer resp.Body.Close()
51+
if resp.StatusCode != http.StatusNotFound {
52+
t.Fatalf("status = %d, want 404", resp.StatusCode)
53+
}
54+
})
55+
}

internal/controller/cluster_controller_plan.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,27 @@ package controller
22

33
import (
44
"fmt"
5+
"sync"
56

67
"github.com/gnmic/operator/internal/gnmic"
78
)
89

10+
// NewClusterReconcilerForTest returns a ClusterReconciler with an initialized plan cache.
11+
// It is intended for unit tests of components that read cached plans (e.g. the API server).
12+
func NewClusterReconcilerForTest() *ClusterReconciler {
13+
return &ClusterReconciler{
14+
m: &sync.RWMutex{},
15+
plans: make(map[string]*gnmic.ApplyPlan),
16+
}
17+
}
18+
19+
// CachePlan stores an apply plan in the reconciler's in-memory cache.
20+
func (r *ClusterReconciler) CachePlan(namespace, name string, plan *gnmic.ApplyPlan) {
21+
r.m.Lock()
22+
defer r.m.Unlock()
23+
r.plans[namespace+"/"+name] = plan
24+
}
25+
926
func (r *ClusterReconciler) GetClusterPlan(namespace, name string) (*gnmic.ApplyPlan, error) {
1027
r.m.RLock()
1128
defer r.m.RUnlock()
Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
1+
package controller
2+
3+
import (
4+
"context"
5+
"testing"
6+
"time"
7+
8+
gnmicv1alpha1 "github.com/gnmic/operator/api/v1alpha1"
9+
"github.com/gnmic/operator/internal/gnmic"
10+
corev1 "k8s.io/api/core/v1"
11+
"k8s.io/apimachinery/pkg/api/resource"
12+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
13+
"k8s.io/apimachinery/pkg/runtime"
14+
"k8s.io/apimachinery/pkg/types"
15+
ctrl "sigs.k8s.io/controller-runtime"
16+
"sigs.k8s.io/controller-runtime/pkg/client"
17+
"sigs.k8s.io/controller-runtime/pkg/client/fake"
18+
"sigs.k8s.io/controller-runtime/pkg/event"
19+
)
20+
21+
func TestClusterStatusEqual(t *testing.T) {
22+
a := gnmicv1alpha1.ClusterStatus{ReadyReplicas: 1, Conditions: []metav1.Condition{{Type: "Ready", Status: metav1.ConditionTrue}}}
23+
b := a
24+
if !clusterStatusEqual(a, b) {
25+
t.Fatal("expected equal status")
26+
}
27+
b.ReadyReplicas = 2
28+
if clusterStatusEqual(a, b) {
29+
t.Fatal("expected different status")
30+
}
31+
}
32+
33+
func TestPipelineReferencesResource(t *testing.T) {
34+
pipeline := &gnmicv1alpha1.Pipeline{
35+
Spec: gnmicv1alpha1.PipelineSpec{
36+
TargetRefs: []string{"my-target"},
37+
TargetSelectors: []metav1.LabelSelector{{
38+
MatchLabels: map[string]string{"app": "router"},
39+
}},
40+
},
41+
}
42+
if !pipelineReferencesResource(pipeline, "my-target", nil, "target") {
43+
t.Fatal("expected ref match")
44+
}
45+
if !pipelineReferencesResource(pipeline, "other", map[string]string{"app": "router"}, "target") {
46+
t.Fatal("expected selector match")
47+
}
48+
if pipelineReferencesResource(pipeline, "x", nil, "unknown") {
49+
t.Fatal("expected false for unknown type")
50+
}
51+
}
52+
53+
func TestParseListenPortAndPath(t *testing.T) {
54+
port, path, err := parseListenPortAndPath([]byte(`listen: ":9805"`))
55+
if err != nil || port != 9805 || path != "/metrics" {
56+
t.Fatalf("port=%d path=%q err=%v", port, path, err)
57+
}
58+
59+
port, path, err = parseListenPortAndPath([]byte("listen: \"127.0.0.1:8080\"\npath: /custom\n"))
60+
if err != nil || port != 8080 || path != "/custom" {
61+
t.Fatalf("port=%d path=%q err=%v", port, path, err)
62+
}
63+
64+
if _, _, err := parseListenPortAndPath([]byte("not yaml")); err == nil {
65+
t.Fatal("expected yaml error")
66+
}
67+
}
68+
69+
func TestComputeStatusSummary(t *testing.T) {
70+
status := &gnmicv1alpha1.TargetStatus{
71+
ClusterStates: map[string]gnmicv1alpha1.ClusterTargetState{
72+
"c1": {State: "running", ConnectionState: "READY"},
73+
},
74+
}
75+
computeStatusSummary(status)
76+
if status.State != "READY" || status.Clusters != 1 {
77+
t.Fatalf("status = %+v", status)
78+
}
79+
80+
status.ClusterStates["c2"] = gnmicv1alpha1.ClusterTargetState{State: "failed"}
81+
computeStatusSummary(status)
82+
if status.State != "DEGRADED" {
83+
t.Fatalf("expected DEGRADED, got %q", status.State)
84+
}
85+
}
86+
87+
func TestParseTargetNameAndStreamKey(t *testing.T) {
88+
ns, name, ok := parseTargetName("default/router1")
89+
if !ok || ns != "default" || name != "router1" {
90+
t.Fatalf("parseTargetName = %q %q %v", ns, name, ok)
91+
}
92+
if key := streamKey("default", "cluster", 2); key != "default/cluster/2" {
93+
t.Fatalf("streamKey = %q", key)
94+
}
95+
}
96+
97+
func TestBackoff(t *testing.T) {
98+
if got := backoff(time.Second); got != 2*time.Second {
99+
t.Fatalf("backoff = %v", got)
100+
}
101+
if got := backoff(reconnectMaxDelay); got != reconnectMaxDelay {
102+
t.Fatalf("backoff cap = %v", got)
103+
}
104+
}
105+
106+
func TestBuildContainerPorts(t *testing.T) {
107+
cluster := &gnmicv1alpha1.Cluster{
108+
Spec: gnmicv1alpha1.ClusterSpec{
109+
API: &gnmicv1alpha1.APIConfig{
110+
RestPort: 8080,
111+
GNMIPort: 9339,
112+
},
113+
GRPCTunnel: &gnmicv1alpha1.GRPCTunnelConfig{Port: 57400},
114+
},
115+
}
116+
ports := buildContainerPorts(cluster)
117+
if len(ports) != 3 {
118+
t.Fatalf("ports = %d", len(ports))
119+
}
120+
}
121+
122+
func TestExtractOrdinalFromCertName(t *testing.T) {
123+
r := &ClusterReconciler{}
124+
if got := r.extractOrdinalFromCertName("gnmic-cluster-2-tls", "gnmic-cluster"); got != 2 {
125+
t.Fatalf("ordinal = %d", got)
126+
}
127+
if got := r.extractOrdinalFromCertName("invalid", "gnmic-cluster"); got != -1 {
128+
t.Fatalf("expected -1, got %d", got)
129+
}
130+
}
131+
132+
func TestPipelineStatusEqual(t *testing.T) {
133+
a := gnmicv1alpha1.PipelineStatus{Status: "Ready", TargetsCount: 1}
134+
if !pipelineStatusEqual(a, a) {
135+
t.Fatal("expected equal")
136+
}
137+
b := a
138+
b.TargetsCount = 2
139+
if pipelineStatusEqual(a, b) {
140+
t.Fatal("expected different")
141+
}
142+
}
143+
144+
func TestResourcesEqual(t *testing.T) {
145+
req := corev1.ResourceRequirements{
146+
Requests: corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("100m")},
147+
}
148+
if !resourcesEqual(req, req) {
149+
t.Fatal("expected equal resources")
150+
}
151+
}
152+
153+
func TestGenerationOrLabelsChangedPredicate(t *testing.T) {
154+
p := generationOrLabelsChangedPredicate{}
155+
old := &gnmicv1alpha1.Cluster{ObjectMeta: metav1.ObjectMeta{Generation: 1, Labels: map[string]string{"a": "1"}}}
156+
new := old.DeepCopy()
157+
if p.Update(event.UpdateEvent{ObjectOld: old, ObjectNew: new}) {
158+
t.Fatal("expected false when unchanged")
159+
}
160+
new.Generation = 2
161+
if !p.Update(event.UpdateEvent{ObjectOld: old, ObjectNew: new}) {
162+
t.Fatal("expected true on generation change")
163+
}
164+
}
165+
166+
func TestPipelineReconciler(t *testing.T) {
167+
scheme := runtime.NewScheme()
168+
if err := gnmicv1alpha1.AddToScheme(scheme); err != nil {
169+
t.Fatal(err)
170+
}
171+
172+
cluster := &gnmicv1alpha1.Cluster{
173+
ObjectMeta: metav1.ObjectMeta{Name: "c1", Namespace: "default"},
174+
}
175+
pipeline := &gnmicv1alpha1.Pipeline{
176+
ObjectMeta: metav1.ObjectMeta{Name: "p1", Namespace: "default"},
177+
Spec: gnmicv1alpha1.PipelineSpec{
178+
ClusterRef: "c1",
179+
Enabled: true,
180+
},
181+
}
182+
cl := fake.NewClientBuilder().
183+
WithScheme(scheme).
184+
WithObjects(cluster, pipeline).
185+
WithStatusSubresource(&gnmicv1alpha1.Pipeline{}).
186+
Build()
187+
188+
r := &PipelineReconciler{Client: cl, Scheme: scheme}
189+
res, err := r.Reconcile(context.Background(), ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "default", Name: "p1"}})
190+
if err != nil {
191+
t.Fatal(err)
192+
}
193+
if res.RequeueAfter != 0 {
194+
t.Fatalf("unexpected requeue: %+v", res)
195+
}
196+
197+
var updated gnmicv1alpha1.Pipeline
198+
if err := cl.Get(context.Background(), client.ObjectKeyFromObject(pipeline), &updated); err != nil {
199+
t.Fatal(err)
200+
}
201+
if updated.Status.Status != "Ready" {
202+
t.Fatalf("status = %q", updated.Status.Status)
203+
}
204+
205+
// missing cluster
206+
pipeline2 := &gnmicv1alpha1.Pipeline{
207+
ObjectMeta: metav1.ObjectMeta{Name: "p2", Namespace: "default"},
208+
Spec: gnmicv1alpha1.PipelineSpec{ClusterRef: "missing"},
209+
}
210+
cl2 := fake.NewClientBuilder().
211+
WithScheme(scheme).
212+
WithObjects(pipeline2).
213+
WithStatusSubresource(&gnmicv1alpha1.Pipeline{}).
214+
Build()
215+
r2 := &PipelineReconciler{Client: cl2, Scheme: scheme}
216+
res, err = r2.Reconcile(context.Background(), ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "default", Name: "p2"}})
217+
if err != nil {
218+
t.Fatal(err)
219+
}
220+
if res.RequeueAfter != 30*time.Second {
221+
t.Fatalf("requeue = %v", res.RequeueAfter)
222+
}
223+
224+
// disabled pipeline
225+
pipeline3 := &gnmicv1alpha1.Pipeline{
226+
ObjectMeta: metav1.ObjectMeta{Name: "p3", Namespace: "default"},
227+
Spec: gnmicv1alpha1.PipelineSpec{ClusterRef: "c1", Enabled: false},
228+
}
229+
cl3 := fake.NewClientBuilder().
230+
WithScheme(scheme).
231+
WithObjects(cluster, pipeline3).
232+
WithStatusSubresource(&gnmicv1alpha1.Pipeline{}).
233+
Build()
234+
r3 := &PipelineReconciler{Client: cl3, Scheme: scheme}
235+
if _, err := r3.Reconcile(context.Background(), ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "default", Name: "p3"}}); err != nil {
236+
t.Fatal(err)
237+
}
238+
var updated3 gnmicv1alpha1.Pipeline
239+
_ = cl3.Get(context.Background(), client.ObjectKeyFromObject(pipeline3), &updated3)
240+
if updated3.Status.Status != "Disabled" {
241+
t.Fatalf("status = %q", updated3.Status.Status)
242+
}
243+
}
244+
245+
func TestTunnelTargetPolicyReconciler(t *testing.T) {
246+
scheme := runtime.NewScheme()
247+
_ = gnmicv1alpha1.AddToScheme(scheme)
248+
r := &TunnelTargetPolicyReconciler{Client: fake.NewClientBuilder().WithScheme(scheme).Build(), Scheme: scheme}
249+
if _, err := r.Reconcile(context.Background(), ctrl.Request{}); err != nil {
250+
t.Fatal(err)
251+
}
252+
}
253+
254+
func TestGetClusterPlan(t *testing.T) {
255+
r := NewClusterReconcilerForTest()
256+
plan := &gnmic.ApplyPlan{}
257+
r.CachePlan("ns", "c", plan)
258+
259+
got, err := r.GetClusterPlan("ns", "c")
260+
if err != nil || got != plan {
261+
t.Fatalf("GetClusterPlan: plan=%p err=%v", got, err)
262+
}
263+
if _, err := r.GetClusterPlan("ns", "missing"); err == nil {
264+
t.Fatal("expected not found")
265+
}
266+
r.cleanupPlan("ns", "c")
267+
if _, err := r.GetClusterPlan("ns", "c"); err == nil {
268+
t.Fatal("expected plan removed")
269+
}
270+
}

0 commit comments

Comments
 (0)