* Copyright (c) 2024 Huawei Technologies Co., Ltd.
* openFuyao is licensed under Mulan PSL v2.
* You can use this software according to the terms and conditions of the Mulan PSL v2.
* You may obtain a copy of Mulan PSL v2 at:
* http://license.coscl.org.cn/MulanPSL2
* THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND,
* EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT,
* MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE.
* See the Mulan PSL v2 for more details.
*/
package controller
import (
"context"
"errors"
"reflect"
"testing"
"time"
"github.com/agiledragon/gomonkey/v2"
"github.com/stretchr/testify/assert"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
"k8s.io/client-go/tools/record"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
"sigs.k8s.io/controller-runtime/pkg/client/interceptor"
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
"openfuyao.com/web-terminal-service/api/v1beta1"
)
type reconcileTestCase struct {
name string
existingObjs []client.Object
req ctrl.Request
expectErr bool
verify func(t *testing.T, c client.Client, result ctrl.Result)
}
type updateStatusTestCase struct {
name string
existingObj *v1beta1.WebterminalTemplate
targetObj *v1beta1.WebterminalTemplate
newStatus v1beta1.WebterminalTemplateStatus
mockUpdateFail bool
expectPhase v1beta1.WebTerminalTemplatePhase
expectRequeue time.Duration
}
type updateErrClient struct {
client.Client
err error
}
func (u *updateErrClient) Update(ctx context.Context, obj client.Object, opts ...client.UpdateOption) error {
return u.err
}
type deleteErrClient struct {
client.Client
err error
}
func (d *deleteErrClient) Delete(ctx context.Context, obj client.Object, opts ...client.DeleteOption) error {
return d.err
}
type getErrClient struct {
client.Client
err error
}
func (g *getErrClient) Get(ctx context.Context, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error {
return g.err
}
func setupScheme() *runtime.Scheme {
s := runtime.NewScheme()
utilruntime.Must(clientgoscheme.AddToScheme(s))
utilruntime.Must(v1beta1.AddToScheme(s))
return s
}
func newReadyPod(name, namespace string) *corev1.Pod {
return &corev1.Pod{
ObjectMeta: metav1.ObjectMeta{
Name: name,
Namespace: namespace,
},
Status: corev1.PodStatus{
Conditions: []corev1.PodCondition{
{
Type: corev1.PodReady,
Status: corev1.ConditionTrue,
},
},
},
}
}
func TestWebterminalTemplateReconcilerReconcile(t *testing.T) {
scheme := setupScheme()
testCases := getReconcileTestCases()
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
executeReconcileTest(t, tc, scheme)
})
}
}
const (
bufferSize = 10
updateTime = -30 * time.Minute
)
func executeReconcileTest(t *testing.T, tc reconcileTestCase, scheme *runtime.Scheme) {
clientBuilder := fake.NewClientBuilder().
WithScheme(scheme).
WithObjects(tc.existingObjs...).
WithStatusSubresource(tc.existingObjs...)
fakeClient := clientBuilder.Build()
recorder := record.NewFakeRecorder(bufferSize)
r := &WebterminalTemplateReconciler{
Client: fakeClient,
Scheme: scheme,
Recorder: recorder,
}
result, err := r.Reconcile(context.Background(), tc.req)
if tc.expectErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
if tc.verify != nil {
tc.verify(t, fakeClient, result)
}
}
func getReconcileTestCases() []reconcileTestCase {
now := metav1.Now()
return []reconcileTestCase{
{
name: "Scenario 1: Resource Not Found",
existingObjs: []client.Object{},
req: ctrl.Request{
NamespacedName: types.NamespacedName{Name: "test-term", Namespace: "default"},
},
expectErr: false,
verify: func(t *testing.T, c client.Client, result ctrl.Result) {
assert.Equal(t, ctrl.Result{}, result)
},
},
{
name: "Scenario 2: Add Finalizer",
existingObjs: []client.Object{
&v1beta1.WebterminalTemplate{
ObjectMeta: metav1.ObjectMeta{Name: "test-term", Namespace: "default"},
Spec: v1beta1.WebterminalTemplateSpec{
PodTemplate: v1beta1.PodTemplate{
ObjectMeta: v1beta1.PodTemplateObjectMeta{Name: "test-term", Namespace: "default"},
Spec: v1beta1.PodTemplateSpec{Containers: []corev1.Container{{Name: "c", Image: "i"}}},
},
},
},
newReadyPod("test-term", "default"),
},
req: ctrl.Request{NamespacedName: types.NamespacedName{Name: "test-term", Namespace: "default"}},
expectErr: false,
verify: func(t *testing.T, c client.Client, result ctrl.Result) {
fetched := &v1beta1.WebterminalTemplate{}
_ = c.Get(context.TODO(), types.NamespacedName{Name: "test-term", Namespace: "default"}, fetched)
assert.Contains(t, fetched.Finalizers, finalizer)
},
},
{
name: "Scenario 3: TTL Expiration",
existingObjs: []client.Object{
&v1beta1.WebterminalTemplate{
ObjectMeta: metav1.ObjectMeta{
Name: "expired-term",
Namespace: "default",
Finalizers: []string{finalizer},
},
Spec: v1beta1.WebterminalTemplateSpec{
RenewTime: metav1.NewTime(time.Now().Add(updateTime)),
ExistsTime: metav1.NewTime(time.Now().Add(updateTime)),
},
Status: v1beta1.WebterminalTemplateStatus{Phase: v1beta1.WebTerminalTemplateRunning},
},
},
req: ctrl.Request{NamespacedName: types.NamespacedName{Name: "expired-term", Namespace: "default"}},
expectErr: false,
verify: func(t *testing.T, c client.Client, result ctrl.Result) {
fetched := &v1beta1.WebterminalTemplate{}
err := c.Get(context.TODO(), types.NamespacedName{Name: "expired-term", Namespace: "default"}, fetched)
assert.NoError(t, err)
assert.False(t, fetched.DeletionTimestamp.IsZero(), "Object should be marked for deletion")
},
},
{
name: "Scenario 4: Logic after Pod Creation",
existingObjs: []client.Object{
&v1beta1.WebterminalTemplate{
ObjectMeta: metav1.ObjectMeta{
Name: "new-term",
Namespace: "default",
Finalizers: []string{finalizer},
},
Spec: v1beta1.WebterminalTemplateSpec{
PodTemplate: v1beta1.PodTemplate{
ObjectMeta: v1beta1.PodTemplateObjectMeta{Name: "new-term", Namespace: "default"},
Spec: v1beta1.PodTemplateSpec{
Containers: []corev1.Container{{Name: "shell", Image: "busybox"}},
},
},
},
},
newReadyPod("new-term", "default"),
},
req: ctrl.Request{NamespacedName: types.NamespacedName{Name: "new-term", Namespace: "default"}},
expectErr: false,
verify: func(t *testing.T, c client.Client, result ctrl.Result) {
fetched := &v1beta1.WebterminalTemplate{}
err := c.Get(context.TODO(), types.NamespacedName{Name: "new-term", Namespace: "default"}, fetched)
assert.NoError(t, err)
assert.False(t, fetched.Spec.ExistsTime.IsZero())
assert.Equal(t, v1beta1.WebTerminalTemplateRunning, fetched.Status.Phase)
},
},
{
name: "Scenario 5: Finalizer Cleanup",
existingObjs: []client.Object{
&v1beta1.WebterminalTemplate{
ObjectMeta: metav1.ObjectMeta{
Name: "deleting-term",
Namespace: "default",
Finalizers: []string{finalizer},
DeletionTimestamp: &now,
},
},
newReadyPod("deleting-term", "default"),
},
req: ctrl.Request{NamespacedName: types.NamespacedName{Name: "deleting-term", Namespace: "default"}},
expectErr: false,
verify: func(t *testing.T, c client.Client, result ctrl.Result) {
pod := &corev1.Pod{}
err := c.Get(context.TODO(), types.NamespacedName{Name: "deleting-term", Namespace: "default"}, pod)
assert.True(t, apierrors.IsNotFound(err), "Pod should be deleted")
fetched := &v1beta1.WebterminalTemplate{}
err = c.Get(context.TODO(), types.NamespacedName{Name: "deleting-term", Namespace: "default"}, fetched)
assert.True(t, apierrors.IsNotFound(err), "Template should be deleted")
},
},
}
}
func TestWebterminalTemplateReconcilerUpdateStatus(t *testing.T) {
scheme := setupScheme()
testCases := getUpdateStatusTestCases()
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
executeUpdateStatusTest(t, tc, scheme)
})
}
}
func executeUpdateStatusTest(t *testing.T, tc updateStatusTestCase, scheme *runtime.Scheme) {
builder := fake.NewClientBuilder().WithScheme(scheme)
if tc.existingObj != nil {
builder.WithObjects(tc.existingObj).WithStatusSubresource(tc.existingObj)
}
if tc.mockUpdateFail {
builder.WithInterceptorFuncs(interceptor.Funcs{
SubResourceUpdate: func(ctx context.Context, client client.Client, subResourceName string,
obj client.Object, opts ...client.SubResourceUpdateOption) error {
return errors.New("mock update error")
},
})
}
fakeClient := builder.Build()
r := &WebterminalTemplateReconciler{
Client: fakeClient,
Scheme: scheme,
}
result, err := r.updateStatus(context.Background(), tc.targetObj, tc.newStatus)
assert.NoError(t, err)
assert.Equal(t, tc.expectRequeue, result.RequeueAfter)
if tc.existingObj != nil && !tc.mockUpdateFail {
updatedObj := &v1beta1.WebterminalTemplate{}
err := fakeClient.Get(context.Background(),
types.NamespacedName{Name: tc.targetObj.Name, Namespace: tc.targetObj.Namespace}, updatedObj)
assert.NoError(t, err)
assert.Equal(t, tc.expectPhase, updatedObj.Status.Phase)
if len(tc.newStatus.Conditions) > 0 {
assert.Equal(t, tc.newStatus.Conditions[0].Reason, updatedObj.Status.Conditions[0].Reason)
}
}
}
func getUpdateStatusTestCases() []updateStatusTestCase {
return []updateStatusTestCase{
{
name: "Success: Status should be updated",
existingObj: &v1beta1.WebterminalTemplate{
ObjectMeta: metav1.ObjectMeta{Name: "term-1", Namespace: "default"},
Status: v1beta1.WebterminalTemplateStatus{Phase: v1beta1.WebTerminalTemplateStarting},
},
targetObj: &v1beta1.WebterminalTemplate{
ObjectMeta: metav1.ObjectMeta{Name: "term-1", Namespace: "default"},
},
newStatus: v1beta1.WebterminalTemplateStatus{
Phase: v1beta1.WebTerminalTemplateRunning,
Conditions: []v1beta1.WebTerminalTemplateCondition{
{Reason: "Test", Status: "True"},
},
},
expectPhase: v1beta1.WebTerminalTemplateRunning,
expectRequeue: defaultUpdateFrequency,
},
{
name: "Failure: Object not found",
existingObj: nil,
targetObj: &v1beta1.WebterminalTemplate{
ObjectMeta: metav1.ObjectMeta{Name: "term-missing", Namespace: "default"},
},
newStatus: v1beta1.WebterminalTemplateStatus{Phase: v1beta1.WebTerminalTemplateRunning},
expectRequeue: 0,
},
{
name: "Failure: Update fails",
existingObj: &v1beta1.WebterminalTemplate{
ObjectMeta: metav1.ObjectMeta{Name: "term-fail", Namespace: "default"},
},
targetObj: &v1beta1.WebterminalTemplate{
ObjectMeta: metav1.ObjectMeta{Name: "term-fail", Namespace: "default"},
},
newStatus: v1beta1.WebterminalTemplateStatus{Phase: v1beta1.WebTerminalTemplateRunning},
mockUpdateFail: true,
expectRequeue: 0,
},
}
}
func TestHandleFinalizer_AddFinalizerFail(t *testing.T) {
r := &WebterminalTemplateReconciler{}
wt := &v1beta1.WebterminalTemplate{
ObjectMeta: metav1.ObjectMeta{Name: "t1", Namespace: "default"},
}
patch := gomonkey.ApplyFunc(controllerutil.AddFinalizer, func(obj client.Object, finalizer string) bool {
return false
})
defer patch.Reset()
shouldReturn, result, err := r.handleFinalizer(context.Background(), wt)
assert.True(t, shouldReturn)
assert.Equal(t, ctrl.Result{}, result)
assert.NoError(t, err)
}
func TestHandleFinalizer_UpdateError(t *testing.T) {
r := &WebterminalTemplateReconciler{
Client: &updateErrClient{
Client: fake.NewClientBuilder().Build(),
err: errors.New("update error"),
},
}
wt := &v1beta1.WebterminalTemplate{
ObjectMeta: metav1.ObjectMeta{Name: "t1", Namespace: "default"},
}
patch1 := gomonkey.ApplyFunc(controllerutil.AddFinalizer, func(obj client.Object, finalizer string) bool {
return true
})
defer patch1.Reset()
shouldReturn, _, err := r.handleFinalizer(context.Background(), wt)
assert.True(t, shouldReturn)
assert.Error(t, err)
}
func TestHandleFinalizer_DeletePathDeleteError(t *testing.T) {
r := &WebterminalTemplateReconciler{}
now := metav1.Now()
wt := &v1beta1.WebterminalTemplate{
ObjectMeta: metav1.ObjectMeta{
Name: "t1",
Namespace: "default",
Finalizers: []string{finalizer},
DeletionTimestamp: &now,
},
}
patch1 := gomonkey.ApplyFunc(controllerutil.ContainsFinalizer, func(obj client.Object, f string) bool { return true })
patch2 := gomonkey.ApplyPrivateMethod(reflect.TypeOf(r), "deletePodTemplate",
func(_ *WebterminalTemplateReconciler, ctx context.Context, obj *v1beta1.WebterminalTemplate) error {
return errors.New("delete error")
})
defer patch1.Reset()
defer patch2.Reset()
shouldReturn, _, err := r.handleFinalizer(context.Background(), wt)
assert.True(t, shouldReturn)
assert.Error(t, err)
}
func TestHandleFinalizer_RemoveFinalizerFail(t *testing.T) {
r := &WebterminalTemplateReconciler{}
now := metav1.Now()
wt := &v1beta1.WebterminalTemplate{
ObjectMeta: metav1.ObjectMeta{
Name: "t1",
Namespace: "default",
Finalizers: []string{finalizer},
DeletionTimestamp: &now,
},
}
patch1 := gomonkey.ApplyFunc(controllerutil.ContainsFinalizer, func(obj client.Object, f string) bool { return true })
patch2 := gomonkey.ApplyPrivateMethod(reflect.TypeOf(r), "deletePodTemplate",
func(_ *WebterminalTemplateReconciler, ctx context.Context, obj *v1beta1.WebterminalTemplate) error {
return nil
})
patch3 := gomonkey.ApplyFunc(controllerutil.RemoveFinalizer, func(obj client.Object, f string) bool { return false })
defer patch1.Reset()
defer patch2.Reset()
defer patch3.Reset()
shouldReturn, _, err := r.handleFinalizer(context.Background(), wt)
assert.True(t, shouldReturn)
assert.NoError(t, err)
}
func TestEnsurePodRunning_CreateError(t *testing.T) {
r := &WebterminalTemplateReconciler{}
wt := &v1beta1.WebterminalTemplate{
Spec: v1beta1.WebterminalTemplateSpec{},
}
patch := gomonkey.ApplyPrivateMethod(reflect.TypeOf(r), "createPodTemplate",
func(_ *WebterminalTemplateReconciler, ctx context.Context, obj *v1beta1.WebterminalTemplate) error {
return errors.New("create error")
})
defer patch.Reset()
shouldReturn, _, err := r.ensurePodRunning(context.Background(), wt)
assert.True(t, shouldReturn)
assert.Error(t, err)
}
func TestEnsurePodRunning_UpdateError(t *testing.T) {
r := &WebterminalTemplateReconciler{
Client: &updateErrClient{
Client: fake.NewClientBuilder().Build(),
err: errors.New("update error"),
},
}
wt := &v1beta1.WebterminalTemplate{
Spec: v1beta1.WebterminalTemplateSpec{},
}
patch1 := gomonkey.ApplyPrivateMethod(reflect.TypeOf(r), "createPodTemplate",
func(_ *WebterminalTemplateReconciler, ctx context.Context, obj *v1beta1.WebterminalTemplate) error {
return nil
})
defer patch1.Reset()
shouldReturn, _, err := r.ensurePodRunning(context.Background(), wt)
assert.True(t, shouldReturn)
assert.Error(t, err)
}
func TestEnsurePodRunning_StatusUpdateError(t *testing.T) {
scheme := setupScheme()
wt := &v1beta1.WebterminalTemplate{
ObjectMeta: metav1.ObjectMeta{Name: "t1", Namespace: "default"},
Spec: v1beta1.WebterminalTemplateSpec{},
}
builder := fake.NewClientBuilder().WithScheme(scheme).WithObjects(wt).WithStatusSubresource(wt)
builder.WithInterceptorFuncs(interceptor.Funcs{
SubResourceUpdate: func(ctx context.Context, c client.Client, subResourceName string,
obj client.Object, opts ...client.SubResourceUpdateOption) error {
return errors.New("status error")
},
})
fakeClient := builder.Build()
r := &WebterminalTemplateReconciler{Client: fakeClient, Scheme: scheme}
patch := gomonkey.ApplyPrivateMethod(reflect.TypeOf(r), "createPodTemplate",
func(_ *WebterminalTemplateReconciler, ctx context.Context, obj *v1beta1.WebterminalTemplate) error {
return nil
})
defer patch.Reset()
shouldReturn, _, err := r.ensurePodRunning(context.Background(), wt)
assert.True(t, shouldReturn)
assert.Error(t, err)
}
func TestCheckTTL_DeleteError(t *testing.T) {
r := &WebterminalTemplateReconciler{
Client: &deleteErrClient{
Client: fake.NewClientBuilder().Build(),
err: errors.New("delete error"),
},
}
wt := &v1beta1.WebterminalTemplate{
Spec: v1beta1.WebterminalTemplateSpec{
RenewTime: metav1.NewTime(time.Now().Add(-checkTimePeriod * 2)),
},
}
shouldReturn, _, err := r.checkTTL(context.Background(), wt)
assert.True(t, shouldReturn)
assert.Error(t, err)
}
func TestSyncStatus_PodGetError(t *testing.T) {
r := &WebterminalTemplateReconciler{
Client: &getErrClient{
Client: fake.NewClientBuilder().Build(),
err: errors.New("get error"),
},
}
wt := &v1beta1.WebterminalTemplate{
ObjectMeta: metav1.ObjectMeta{Name: "t1", Namespace: "default"},
Status: v1beta1.WebterminalTemplateStatus{Phase: v1beta1.WebTerminalTemplateRunning},
}
_, err := r.syncStatus(context.Background(), wt)
assert.Error(t, err)
}