Skip to content

Commit a9ca8b7

Browse files
committed
feat: delete unwanted CoderWorkspacePool members new before old, release used members, and delete unclaimed members when consent goes
Change-Id: If2ba2072d7cfa7379495a67e3b8f0ea7e4524246 Signed-off-by: Thomas Kosiewski <tk@coder.com>
1 parent 007545e commit a9ca8b7

3 files changed

Lines changed: 316 additions & 4 deletions

File tree

‎internal/controller/coderworkspacepool_controller.go‎

Lines changed: 120 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package controller
22

33
import (
4+
"cmp"
45
"context"
56
"errors"
67
"fmt"
@@ -112,6 +113,9 @@ func (r *CoderWorkspacePoolReconciler) Reconcile(ctx context.Context, req ctrl.R
112113
members = append(members, m)
113114
}
114115
}
116+
if err := r.releaseUsedMembers(ctx, pool, members); err != nil {
117+
return ctrl.Result{}, err
118+
}
115119
if err := r.deleteFailedMembers(ctx, pool, before, members); err != nil {
116120
return ctrl.Result{}, err
117121
}
@@ -142,6 +146,9 @@ func (r *CoderWorkspacePoolReconciler) Reconcile(ctx context.Context, req ctrl.R
142146
return ctrl.Result{}, err
143147
}
144148
}
149+
if err := r.deleteUnclaimedMembers(ctx, pool, members, step); err != nil {
150+
return ctrl.Result{}, err
151+
}
145152
r.applyPoolStatus(pool, counts, step)
146153
if err := r.writePoolStatus(ctx, pool, before); err != nil {
147154
return ctrl.Result{}, err
@@ -240,6 +247,118 @@ func (r *CoderWorkspacePoolReconciler) deleteFailedMembers(
240247
return errors.Join(errs...)
241248
}
242249

250+
// releaseUsedMembers removes the pool's controller reference from its
251+
// unclaimed members that someone used (plan 6.3 step 4, decision D6). A
252+
// used member then never counts as unclaimed again, and neither the pool
253+
// nor its deletion deletes it. It keeps the pool label for
254+
// status.usedReplicas. The update carries the listed resourceVersion, and
255+
// the listed members stay as they were, so the later steps decide on the
256+
// list.
257+
func (r *CoderWorkspacePoolReconciler) releaseUsedMembers(
258+
ctx context.Context, pool *coderv1alpha1.CoderWorkspacePool, members []coderv1alpha1.CoderWorkspacePoolMember,
259+
) error {
260+
for i := range members {
261+
m := &members[i]
262+
controller := metav1.GetControllerOf(m)
263+
if controller == nil || controller.UID != pool.UID || m.Spec.ClaimRef != nil || !m.DeletionTimestamp.IsZero() ||
264+
!meta.IsStatusConditionTrue(m.Status.Conditions, coderv1alpha1.CoderWorkspacePoolMemberConditionUsed) {
265+
continue
266+
}
267+
released := m.DeepCopy()
268+
released.OwnerReferences = slices.DeleteFunc(released.OwnerReferences, func(o metav1.OwnerReference) bool { return o.UID == pool.UID })
269+
if err := r.Update(ctx, released); err != nil {
270+
return fmt.Errorf("release used pool member %s/%s: %w", m.Namespace, m.Name, err)
271+
}
272+
}
273+
return nil
274+
}
275+
276+
// poolConsentLostReasons are the waits after which no unclaimed member may
277+
// keep its workspace: each one holds the owner's token from its start build.
278+
var poolConsentLostReasons = []string{"OwnerNotAllowed", "TemplateNotAllowed", "OwnerNotEligible"}
279+
280+
// deleteUnclaimedMembers deletes the pool's unclaimed members that it no
281+
// longer wants (plan 6.3 steps 8 and 9). The deletes carry the listed UID and
282+
// resourceVersion, so a member that its reconciler marked used after the
283+
// list answers 409 and stays. Bound, used, failed, and deleting members never
284+
// go here. After a consent removal or an owner refusal, all other unclaimed
285+
// members go. Otherwise, new before old: outdated members that are not ready
286+
// go at once, and outdated ready members go oldest first, down to what the
287+
// current ready members do not cover yet. Current members above
288+
// spec.replicas go in this order: those without a create request, those not
289+
// ready (newest first), then ready ones (oldest first).
290+
func (r *CoderWorkspacePoolReconciler) deleteUnclaimedMembers(
291+
ctx context.Context, pool *coderv1alpha1.CoderWorkspacePool, members []coderv1alpha1.CoderWorkspacePoolMember, step *coderStep,
292+
) error {
293+
want := *pool.Spec.Replicas // Claims add their demand in PR 11.
294+
var current, outdatedReady, doomed []*coderv1alpha1.CoderWorkspacePoolMember
295+
var readyCurrent int32
296+
for i := range members {
297+
m := &members[i]
298+
controller := metav1.GetControllerOf(m)
299+
if controller == nil || controller.UID != pool.UID || m.Spec.ClaimRef != nil || !m.DeletionTimestamp.IsZero() ||
300+
m.Status.Phase == coderv1alpha1.CoderWorkspacePoolMemberPhaseFailed ||
301+
meta.IsStatusConditionTrue(m.Status.Conditions, coderv1alpha1.CoderWorkspacePoolMemberConditionUsed) {
302+
continue
303+
}
304+
switch ready := poolMemberReady(m); {
305+
case step != nil && slices.Contains(poolConsentLostReasons, step.reason):
306+
doomed = append(doomed, m)
307+
case poolMemberInputMismatch(m, pool) == "":
308+
current = append(current, m)
309+
if ready {
310+
readyCurrent++
311+
}
312+
case ready:
313+
outdatedReady = append(outdatedReady, m)
314+
default:
315+
doomed = append(doomed, m)
316+
}
317+
}
318+
readyAt := func(m *coderv1alpha1.CoderWorkspacePoolMember) time.Time {
319+
return cmp.Or(m.Status.ReadyTime, &m.CreationTimestamp).Time
320+
}
321+
// Newest ready first: the members to keep come first.
322+
slices.SortStableFunc(outdatedReady, func(a, b *coderv1alpha1.CoderWorkspacePoolMember) int { return readyAt(b).Compare(readyAt(a)) })
323+
doomed = append(doomed, outdatedReady[min(int(max(want-readyCurrent, 0)), len(outdatedReady)):]...)
324+
if extra := len(current) - int(want); extra > 0 {
325+
rank := func(m *coderv1alpha1.CoderWorkspacePoolMember) int {
326+
switch {
327+
case !poolMemberMayHaveWorkspace(m):
328+
return 0
329+
case !poolMemberReady(m):
330+
return 1
331+
}
332+
return 2
333+
}
334+
slices.SortStableFunc(current, func(a, b *coderv1alpha1.CoderWorkspacePoolMember) int {
335+
if c := cmp.Compare(rank(a), rank(b)); c != 0 {
336+
return c
337+
}
338+
if rank(a) == 2 {
339+
return readyAt(a).Compare(readyAt(b)) // Ready: oldest first.
340+
}
341+
return b.CreationTimestamp.Compare(a.CreationTimestamp.Time) // Newest first.
342+
})
343+
doomed = append(doomed, current[:extra]...)
344+
}
345+
var errs []error
346+
for _, m := range doomed {
347+
uid, version := m.UID, m.ResourceVersion
348+
if err := r.Delete(ctx, m, client.Preconditions{UID: &uid, ResourceVersion: &version}); client.IgnoreNotFound(err) != nil {
349+
errs = append(errs, fmt.Errorf("delete unclaimed pool member %s/%s: %w", m.Namespace, m.Name, err))
350+
}
351+
}
352+
return errors.Join(errs...)
353+
}
354+
355+
// poolMemberReady reports a member that is ready to claim: phase Ready and
356+
// its Ready condition True.
357+
func poolMemberReady(m *coderv1alpha1.CoderWorkspacePoolMember) bool {
358+
return m.Status.Phase == coderv1alpha1.CoderWorkspacePoolMemberPhaseReady &&
359+
meta.IsStatusConditionTrue(m.Status.Conditions, coderv1alpha1.CoderWorkspacePoolMemberConditionReady)
360+
}
361+
243362
// poolMemberFailedAt is when a failed member failed. The member sets its
244363
// WorkspaceDeleted condition first when it fails, so its transition time is
245364
// the failure time. Ready is already False while a member builds, so its
@@ -511,8 +630,7 @@ func countPoolMembers(pool *coderv1alpha1.CoderWorkspacePool, members []coderv1a
511630
if m.Status.Phase != coderv1alpha1.CoderWorkspacePoolMemberPhaseReady {
512631
c.building++
513632
}
514-
ready := m.Status.Phase == coderv1alpha1.CoderWorkspacePoolMemberPhaseReady &&
515-
meta.IsStatusConditionTrue(m.Status.Conditions, coderv1alpha1.CoderWorkspacePoolMemberConditionReady)
633+
ready := poolMemberReady(m)
516634
current := poolMemberInputMismatch(m, pool) == ""
517635
switch {
518636
case current && ready:

‎internal/controller/coderworkspacepool_create_test.go‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,7 @@ func TestPoolOwnerCapUncached(t *testing.T) {
172172
cp.Spec.WorkspacePools.Owners[0].MaxWorkspaces, cp.Spec.WorkspacePools.Owners[0].Templates = new(int32(10)), []string{"default.other"}
173173
require.NoError(t, k8sClient.Update(e.ctx, cp))
174174
reconcileStale(t, func(o *coderv1alpha1.WorkspacePoolOwner) { o.Templates = []string{"default.docker"} })
175-
require.Len(t, e.poolMembers(t, e.pool.Name), 1, "no member after consent is withdrawn")
175+
require.Empty(t, e.poolMembers(t, e.pool.Name), "no create after consent is withdrawn, and the unclaimed member is deleted")
176176
require.Equal(t, "TemplateNotAllowed", e.reconcilePool(t, 0).Status.Reason)
177177
}
178178

@@ -205,11 +205,13 @@ func TestPoolMemberDiscovery(t *testing.T) {
205205
e.reconcilePool(t, 1)
206206
members := e.poolMembers(t, e.pool.Name)
207207
require.Len(t, members, 1)
208+
e.setMemberStatus(t, memberKey(members[0]), readyStatus)
208209
member := e.reconcileMember(t, memberKey(members[0]), 0)
209210
member.Labels = nil
210211
require.NoError(t, k8sClient.Update(e.ctx, member))
211212
// The member no longer matches the pool's inputs, so the pool counts it
212-
// as outdated and builds one current member, but never more.
213+
// as outdated, keeps it while it is ready, and builds one current
214+
// member, but never more.
213215
pool := e.reconcilePool(t, 3)
214216
require.Equal(t, int32(2), pool.Status.Replicas, "the controlled member still counts")
215217
require.Equal(t, int32(1), pool.Status.UpdatedReplicas)
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
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

Comments
 (0)