Path: blob/main/components/ws-manager-mk2/controllers/timeout_controller_test.go
2498 views
// Copyright (c) 2022 Gitpod GmbH. All rights reserved.1// Licensed under the GNU Affero General Public License (AGPL).2// See License-AGPL.txt in the project root for license information.34package controllers56import (7"time"89wsk8s "github.com/gitpod-io/gitpod/common-go/kubernetes"10workspacev1 "github.com/gitpod-io/gitpod/ws-manager/api/crd/v1"11"github.com/google/uuid"12. "github.com/onsi/ginkgo/v2"13. "github.com/onsi/gomega"14metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"15"k8s.io/apimachinery/pkg/types"16"k8s.io/client-go/tools/record"17"k8s.io/utils/pointer"18"sigs.k8s.io/controller-runtime/pkg/client"19"sigs.k8s.io/controller-runtime/pkg/client/fake"20"sigs.k8s.io/controller-runtime/pkg/reconcile"21// . "github.com/onsi/ginkgo/extensions/table"22)2324var _ = Describe("TimeoutController", func() {25Context("timeouts", func() {26var (27now = time.Now()28conf = newTestConfig()29r *TimeoutReconciler30fakeClient client.Client31)32BeforeEach(func() {33var err error34// Use a fake client instead of the envtest's k8s client, such that we can add objects35// with custom CreationTimestamps and check timeout logic.36fakeClient = fake.NewClientBuilder().WithStatusSubresource(&workspacev1.Workspace{}).WithScheme(k8sClient.Scheme()).Build()37r, err = NewTimeoutReconciler(fakeClient, record.NewFakeRecorder(100), conf, &fakeMaintenance{enabled: false})38Expect(err).ToNot(HaveOccurred())39})4041type testCase struct {42phase workspacev1.WorkspacePhase43lastActivityAgo *time.Duration44age time.Duration45customTimeout *time.Duration46customMaxLifetime *time.Duration47update func(ws *workspacev1.Workspace)48updateStatus func(ws *workspacev1.Workspace)49expectTimeout bool50}51DescribeTable("workspace timeouts",52func(tc testCase) {53By("creating a workspace")54ws := newWorkspace(uuid.NewString(), "default")55ws.CreationTimestamp = metav1.NewTime(now.Add(-tc.age))5657if tc.lastActivityAgo != nil {58now := metav1.NewTime(now.Add(-*tc.lastActivityAgo))59ws.Status.LastActivity = &now60}6162Expect(fakeClient.Create(ctx, ws)).To(Succeed())6364updateObjWithRetries(fakeClient, ws, false, func(ws *workspacev1.Workspace) {65if tc.customTimeout != nil {66ws.Spec.Timeout.Time = &metav1.Duration{Duration: *tc.customTimeout}67}68if tc.customMaxLifetime != nil {69ws.Spec.Timeout.MaximumLifetime = &metav1.Duration{Duration: *tc.customMaxLifetime}70}71if tc.update != nil {72tc.update(ws)73}74})75updateObjWithRetries(fakeClient, ws, true, func(ws *workspacev1.Workspace) {76ws.Status.Phase = tc.phase77if tc.updateStatus != nil {78tc.updateStatus(ws)79}80})8182// Run the timeout controller for this workspace.83By("running the TimeoutController reconcile()")84_, err := r.Reconcile(ctx, reconcile.Request{NamespacedName: types.NamespacedName{Name: ws.Name, Namespace: ws.Namespace}})85Expect(err).ToNot(HaveOccurred())8687if tc.expectTimeout {88expectTimeout(fakeClient, ws)89} else {90expectNoTimeout(fakeClient, ws)91}92},93Entry("should timeout creating workspace", testCase{94phase: workspacev1.WorkspacePhaseCreating,95age: 10 * time.Hour,96expectTimeout: true,97}),98Entry("shouldn't timeout active workspace", testCase{99phase: workspacev1.WorkspacePhaseRunning,100lastActivityAgo: pointer.Duration(1 * time.Minute),101age: 10 * time.Hour,102expectTimeout: false,103}),104Entry("should timeout inactive workspace", testCase{105phase: workspacev1.WorkspacePhaseRunning,106lastActivityAgo: pointer.Duration(2 * time.Hour),107age: 10 * time.Hour,108expectTimeout: true,109}),110Entry("should timeout inactive workspace with custom timeout", testCase{111phase: workspacev1.WorkspacePhaseRunning,112// Use a lastActivity that would not trigger the default timeout, but does trigger the custom timeout.113lastActivityAgo: pointer.Duration(time.Duration(conf.Timeouts.RegularWorkspace / 2)),114customTimeout: pointer.Duration(time.Duration(conf.Timeouts.RegularWorkspace / 3)),115age: 10 * time.Hour,116expectTimeout: true,117}),118Entry("should timeout closed workspace", testCase{119phase: workspacev1.WorkspacePhaseRunning,120updateStatus: func(ws *workspacev1.Workspace) {121ws.Status.Conditions = wsk8s.AddUniqueCondition(ws.Status.Conditions, metav1.Condition{122Type: string(workspacev1.WorkspaceConditionClosed),123LastTransitionTime: metav1.Now(),124Status: metav1.ConditionTrue,125})126},127age: 5 * time.Hour,128lastActivityAgo: pointer.Duration(10 * time.Minute),129expectTimeout: true,130}),131Entry("should timeout headless workspace", testCase{132phase: workspacev1.WorkspacePhaseRunning,133update: func(ws *workspacev1.Workspace) {134ws.Spec.Type = workspacev1.WorkspaceTypePrebuild135},136age: 2 * time.Hour,137lastActivityAgo: nil,138expectTimeout: true,139}),140Entry("should timeout workspace with no custom lifetime", testCase{141phase: workspacev1.WorkspacePhaseRunning,142age: 50 * time.Hour,143lastActivityAgo: pointer.Duration(1 * time.Minute),144expectTimeout: true,145}),146Entry("should timeout workspace with custom lifetime", testCase{147phase: workspacev1.WorkspacePhaseRunning,148age: 12 * time.Hour,149customMaxLifetime: pointer.Duration(8 * time.Hour),150lastActivityAgo: pointer.Duration(1 * time.Minute),151expectTimeout: true,152}),153Entry("should timeout after controller restart if no FirstUserActivity", testCase{154phase: workspacev1.WorkspacePhaseRunning,155age: 5 * time.Hour,156lastActivityAgo: nil, // No last activity recorded yet after controller restart.157expectTimeout: true,158}),159Entry("should timeout eventually with no user activity after controller restart", testCase{160phase: workspacev1.WorkspacePhaseRunning,161updateStatus: func(ws *workspacev1.Workspace) {162ws.Status.Conditions = wsk8s.AddUniqueCondition(ws.Status.Conditions, metav1.Condition{163Type: string(workspacev1.WorkspaceConditionFirstUserActivity),164Status: metav1.ConditionTrue,165LastTransitionTime: metav1.NewTime(now.Add(-5 * time.Hour)),166})167},168age: 5 * time.Hour,169lastActivityAgo: nil,170expectTimeout: true,171}),172)173})174175Context("reconciliation", func() {176var r *TimeoutReconciler177BeforeEach(func() {178var err error179r, err = NewTimeoutReconciler(k8sClient, record.NewFakeRecorder(100), newTestConfig(), &fakeMaintenance{enabled: false})180Expect(err).ToNot(HaveOccurred())181})182183It("should requeue timeout reconciles", func() {184ws := newWorkspace(uuid.NewString(), "default")185_ = createWorkspaceExpectPod(ws)186187res, err := r.Reconcile(ctx, reconcile.Request{NamespacedName: types.NamespacedName{Name: ws.Name, Namespace: ws.Namespace}})188Expect(err).To(BeNil())189Expect(r.reconcileInterval).ToNot(BeZero(), "reconcile interval should be > 0, otherwise events will not requeue")190Expect(res.RequeueAfter).To(Equal(r.reconcileInterval))191})192193It("should not requeue when resource is not found", func() {194res, err := r.Reconcile(ctx, reconcile.Request{NamespacedName: types.NamespacedName{Name: "does-not-exist", Namespace: "default"}})195Expect(err).ToNot(HaveOccurred(), "not-found errors should not be returned")196Expect(res.Requeue).To(BeFalse())197Expect(res.RequeueAfter).To(BeZero())198})199200It("should return an error other than not-found", func() {201// Create a different error than "not-found", easiest is to provide an empty name which returns an "invalid request".202_, err := r.Reconcile(ctx, reconcile.Request{NamespacedName: types.NamespacedName{Name: "", Namespace: "default"}})203Expect(err).To(HaveOccurred(), "should return error and requeue")204})205})206})207208func expectNoTimeout(c client.Client, ws *workspacev1.Workspace) {209GinkgoHelper()210By("expecting controller to not timeout workspace")211Consistently(func(g Gomega) {212g.Expect(c.Get(ctx, types.NamespacedName{Name: ws.Name, Namespace: ws.Namespace}, ws)).To(Succeed())213g.Expect(wsk8s.GetCondition(ws.Status.Conditions, string(workspacev1.WorkspaceConditionTimeout))).To(BeNil())214}, duration, interval).Should(Succeed())215}216217func expectTimeout(c client.Client, ws *workspacev1.Workspace) {218GinkgoHelper()219By("expecting controller to timeout workspace")220Eventually(func(g Gomega) {221g.Expect(c.Get(ctx, types.NamespacedName{Name: ws.Name, Namespace: ws.Namespace}, ws)).To(Succeed())222cond := wsk8s.GetCondition(ws.Status.Conditions, string(workspacev1.WorkspaceConditionTimeout))223g.Expect(cond).ToNot(BeNil())224g.Expect(cond.Status).To(Equal(metav1.ConditionTrue))225}, timeout, interval).Should(Succeed())226}227228229