|
| 1 | +package controller_test |
| 2 | + |
| 3 | +import ( |
| 4 | + "testing" |
| 5 | + "time" |
| 6 | + |
| 7 | + "github.com/coder/coder/v2/codersdk" |
| 8 | + "github.com/google/uuid" |
| 9 | + "github.com/stretchr/testify/require" |
| 10 | + apierrors "k8s.io/apimachinery/pkg/api/errors" |
| 11 | + "k8s.io/apimachinery/pkg/api/meta" |
| 12 | + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" |
| 13 | + "k8s.io/apimachinery/pkg/types" |
| 14 | + ctrl "sigs.k8s.io/controller-runtime" |
| 15 | + |
| 16 | + coderv1alpha1 "github.com/coder/coder-k8s/api/v1alpha1" |
| 17 | + "github.com/coder/coder-k8s/internal/controller" |
| 18 | +) |
| 19 | + |
| 20 | +// withWorkspace marks a member as one that sent its create request. |
| 21 | +func withWorkspace(edit func(*coderv1alpha1.CoderWorkspacePoolMemberStatus)) func(*coderv1alpha1.CoderWorkspacePoolMemberStatus) { |
| 22 | + return func(s *coderv1alpha1.CoderWorkspacePoolMemberStatus) { |
| 23 | + if edit != nil { |
| 24 | + edit(s) |
| 25 | + } |
| 26 | + s.WorkspaceID = uuid.NewString() |
| 27 | + } |
| 28 | +} |
| 29 | + |
| 30 | +// usedStatus is a ready member that someone used without a claim. |
| 31 | +var usedStatus = memberPhase(coderv1alpha1.CoderWorkspacePoolMemberPhaseReady, |
| 32 | + memberCondition(coderv1alpha1.CoderWorkspacePoolMemberConditionReady, metav1.ConditionTrue, "WorkspaceReady"), |
| 33 | + memberCondition(coderv1alpha1.CoderWorkspacePoolMemberConditionUsed, metav1.ConditionTrue, "Used")) |
| 34 | + |
| 35 | +// gone reports whether the member no longer exists. |
| 36 | +func (e *poolMemberEnv) gone(t *testing.T, key types.NamespacedName) bool { |
| 37 | + t.Helper() |
| 38 | + return e.reconcileMember(t, key, 0) == nil |
| 39 | +} |
| 40 | + |
| 41 | +// TestPoolDeleteOrder: on a scale-down the pool deletes current members |
| 42 | +// without a create request first, then members that are not ready (newest |
| 43 | +// first), then ready members (oldest first). |
| 44 | +func TestPoolDeleteOrder(t *testing.T) { |
| 45 | + t.Parallel() |
| 46 | + e := newPoolMemberEnv(t) |
| 47 | + e.editPool(t, func(s *coderv1alpha1.CoderWorkspacePoolSpec) { s.Replicas = new(int32(5)) }) |
| 48 | + now := e.clock.Now() |
| 49 | + readyNew := e.createMember(t, nil) |
| 50 | + e.setMemberStatus(t, readyNew, withWorkspace(e.readyAt(now.Add(-time.Minute)))) |
| 51 | + readyOld := e.createMember(t, nil) |
| 52 | + e.setMemberStatus(t, readyOld, withWorkspace(e.readyAt(now.Add(-time.Hour)))) |
| 53 | + building := e.createMember(t, nil) |
| 54 | + e.setMemberStatus(t, building, withWorkspace(nil)) |
| 55 | + time.Sleep(1100 * time.Millisecond) // creationTimestamp has second precision. |
| 56 | + buildingNew := e.createMember(t, nil) |
| 57 | + e.setMemberStatus(t, buildingNew, withWorkspace(nil)) |
| 58 | + pending := e.createMember(t, nil) |
| 59 | + e.reconcilePool(t, 1) |
| 60 | + require.Len(t, e.poolMembers(t, e.pool.Name), 5) |
| 61 | + |
| 62 | + for _, step := range []struct { |
| 63 | + replicas int32 |
| 64 | + deleted types.NamespacedName |
| 65 | + }{{4, pending}, {3, buildingNew}, {2, building}, {1, readyOld}} { |
| 66 | + e.editPool(t, func(s *coderv1alpha1.CoderWorkspacePoolSpec) { s.Replicas = new(step.replicas) }) |
| 67 | + e.reconcilePool(t, 1) |
| 68 | + require.True(t, e.gone(t, step.deleted), "replicas %d deletes %s", step.replicas, step.deleted.Name) |
| 69 | + require.Len(t, e.poolMembers(t, e.pool.Name), int(step.replicas)) |
| 70 | + } |
| 71 | + require.False(t, e.gone(t, readyNew)) |
| 72 | +} |
| 73 | + |
| 74 | +// TestPoolNewBeforeOld: after a version change, outdated members that are |
| 75 | +// not ready go at once, and outdated ready members stay claimable until |
| 76 | +// current members are ready, oldest first. |
| 77 | +func TestPoolNewBeforeOld(t *testing.T) { |
| 78 | + t.Parallel() |
| 79 | + e := newPoolMemberEnv(t) |
| 80 | + e.editPool(t, func(s *coderv1alpha1.CoderWorkspacePoolSpec) { |
| 81 | + s.Replicas, s.MaxInFlight = new(int32(2)), new(int32(2)) |
| 82 | + }) |
| 83 | + v0 := e.fake.addVersion(e.tplID, "v0", codersdk.ProvisionerJobSucceeded).String() |
| 84 | + outdated := func(m *coderv1alpha1.CoderWorkspacePoolMember) { m.Spec.TemplateVersionID = v0 } |
| 85 | + now := e.clock.Now() |
| 86 | + oldest := e.createMember(t, outdated) |
| 87 | + e.setMemberStatus(t, oldest, withWorkspace(e.readyAt(now.Add(-2*time.Hour)))) |
| 88 | + older := e.createMember(t, outdated) |
| 89 | + e.setMemberStatus(t, older, withWorkspace(e.readyAt(now.Add(-time.Hour)))) |
| 90 | + building := e.createMember(t, outdated) |
| 91 | + e.setMemberStatus(t, building, withWorkspace(nil)) |
| 92 | + |
| 93 | + e.reconcilePool(t, 2) |
| 94 | + require.True(t, e.gone(t, building), "an outdated member that is not ready goes at once") |
| 95 | + require.False(t, e.gone(t, oldest)) |
| 96 | + require.False(t, e.gone(t, older)) |
| 97 | + var current []types.NamespacedName |
| 98 | + for _, m := range e.poolMembers(t, e.pool.Name) { |
| 99 | + if m.Spec.TemplateVersionID != v0 { |
| 100 | + current = append(current, memberKey(m)) |
| 101 | + } |
| 102 | + } |
| 103 | + require.Len(t, current, 2) |
| 104 | + |
| 105 | + e.setMemberStatus(t, current[0], withWorkspace(e.readyAt(now))) |
| 106 | + e.reconcilePool(t, 1) |
| 107 | + require.True(t, e.gone(t, oldest), "one current ready member replaces the oldest outdated one") |
| 108 | + require.False(t, e.gone(t, older)) |
| 109 | + e.setMemberStatus(t, current[1], withWorkspace(e.readyAt(now))) |
| 110 | + e.reconcilePool(t, 1) |
| 111 | + require.True(t, e.gone(t, older)) |
| 112 | + requirePool(t, e.reconcilePool(t, 1), "Ready", true, false) |
| 113 | +} |
| 114 | + |
| 115 | +// TestPoolReleasesUsedMember: the pool releases a member that someone used |
| 116 | +// and never deletes it, also not on a scale to zero (mutation check 5). A |
| 117 | +// member that its reconciler marks used after the pool's list answers 409 |
| 118 | +// to the delete and stays. |
| 119 | +func TestPoolReleasesUsedMember(t *testing.T) { |
| 120 | + t.Parallel() |
| 121 | + e := newPoolMemberEnv(t) |
| 122 | + e.editPool(t, func(s *coderv1alpha1.CoderWorkspacePoolSpec) { s.Replicas = new(int32(2)) }) |
| 123 | + used := e.createMember(t, nil) |
| 124 | + e.setMemberStatus(t, used, withWorkspace(usedStatus)) |
| 125 | + ready := e.createMember(t, nil) |
| 126 | + e.setMemberStatus(t, ready, withWorkspace(readyStatus)) |
| 127 | + e.editPool(t, func(s *coderv1alpha1.CoderWorkspacePoolSpec) { s.Replicas = new(int32(0)) }) |
| 128 | + |
| 129 | + pool := e.reconcilePool(t, 2) |
| 130 | + require.True(t, e.gone(t, ready)) |
| 131 | + released := e.reconcileMember(t, used, 0) |
| 132 | + require.NotNil(t, released, "a used member is never deleted") |
| 133 | + require.Nil(t, metav1.GetControllerOf(released), "the pool released it") |
| 134 | + require.Equal(t, string(pool.UID), released.Labels[coderv1alpha1.CoderWorkspacePoolSelectorLabel]) |
| 135 | + require.Equal(t, int32(1), pool.Status.UsedReplicas) |
| 136 | + require.Zero(t, pool.Status.Replicas) |
| 137 | + |
| 138 | + // The member reconciler marks the member used between the list and the |
| 139 | + // delete: the resourceVersion precondition refuses the delete. |
| 140 | + late := e.createMember(t, nil) |
| 141 | + e.setMemberStatus(t, late, withWorkspace(readyStatus)) |
| 142 | + hook := deleteHookClient{Client: k8sClient, deleted: new(false), hook: func(m *coderv1alpha1.CoderWorkspacePoolMember) { |
| 143 | + e.setMemberStatus(t, memberKey(*m), usedStatus) |
| 144 | + }} |
| 145 | + r := &controller.CoderWorkspacePoolReconciler{Client: hook, APIReader: k8sClient, Scheme: scheme, Clock: e.clock} |
| 146 | + _, err := r.Reconcile(e.ctx, ctrl.Request{NamespacedName: types.NamespacedName{Namespace: e.ns, Name: e.pool.Name}}) |
| 147 | + require.True(t, apierrors.IsConflict(err), "the delete conflicts: %v", err) |
| 148 | + e.reconcilePool(t, 1) |
| 149 | + require.Nil(t, metav1.GetControllerOf(e.reconcileMember(t, late, 0)), "the next reconcile releases it") |
| 150 | +} |
| 151 | + |
| 152 | +// TestPoolConsentLossDeletesUnclaimed: when the admin removes the owner or |
| 153 | +// the template, or the owner gains a role, the pool deletes every unclaimed |
| 154 | +// member, because each one holds the owner's token. Bound and used members |
| 155 | +// stay. |
| 156 | +func TestPoolConsentLossDeletesUnclaimed(t *testing.T) { |
| 157 | + t.Parallel() |
| 158 | + for _, tc := range []struct { |
| 159 | + name, reason string |
| 160 | + lose func(t *testing.T, e *poolMemberEnv) |
| 161 | + }{ |
| 162 | + {"template removed", "TemplateNotAllowed", func(t *testing.T, e *poolMemberEnv) { e.allowOwner(t, e.tester.String(), "default.other") }}, |
| 163 | + {"owner removed", "OwnerNotAllowed", func(t *testing.T, e *poolMemberEnv) { e.allowOwner(t, uuid.NewString(), "default.docker") }}, |
| 164 | + {"owner gains an admin role", "OwnerNotEligible", func(_ *testing.T, e *poolMemberEnv) { |
| 165 | + e.fake.updateUser(e.tester, func(u *codersdk.User) { u.Roles = []codersdk.SlimRole{{Name: codersdk.RoleOwner}} }) |
| 166 | + }}, |
| 167 | + } { |
| 168 | + t.Run(tc.name, func(t *testing.T) { |
| 169 | + t.Parallel() |
| 170 | + e := newPoolMemberEnv(t) |
| 171 | + e.editPool(t, func(s *coderv1alpha1.CoderWorkspacePoolSpec) { s.Replicas = new(int32(4)) }) |
| 172 | + ready := e.createMember(t, nil) |
| 173 | + e.setMemberStatus(t, ready, withWorkspace(readyStatus)) |
| 174 | + pending := e.createMember(t, nil) |
| 175 | + used := e.createMember(t, nil) |
| 176 | + e.setMemberStatus(t, used, withWorkspace(usedStatus)) |
| 177 | + bound := e.createMember(t, func(m *coderv1alpha1.CoderWorkspacePoolMember) { |
| 178 | + m.Spec.ClaimRef = &coderv1alpha1.CoderWorkspaceClaimReference{Name: "build-1", UID: uuid.NewString()} |
| 179 | + }) |
| 180 | + e.setMemberStatus(t, bound, withWorkspace(readyStatus)) |
| 181 | + tc.lose(t, e) |
| 182 | + |
| 183 | + pool := e.reconcilePool(t, 1) |
| 184 | + require.Equal(t, tc.reason, pool.Status.Reason, pool.Status.Message) |
| 185 | + require.True(t, e.gone(t, ready)) |
| 186 | + require.True(t, e.gone(t, pending)) |
| 187 | + require.False(t, e.gone(t, used)) |
| 188 | + require.False(t, e.gone(t, bound)) |
| 189 | + require.False(t, meta.IsStatusConditionTrue(pool.Status.Conditions, coderv1alpha1.CoderWorkspacePoolConditionReady)) |
| 190 | + }) |
| 191 | + } |
| 192 | +} |
0 commit comments