Skip to content

Commit 775524e

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 92242ff commit 775524e

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
}
@@ -134,6 +138,9 @@ func (r *CoderWorkspacePoolReconciler) Reconcile(ctx context.Context, req ctrl.R
134138
return ctrl.Result{}, err
135139
}
136140
}
141+
if err := r.deleteUnclaimedMembers(ctx, pool, members, step); err != nil {
142+
return ctrl.Result{}, err
143+
}
137144
r.applyPoolStatus(pool, counts, step)
138145
if err := r.writePoolStatus(ctx, pool, before); err != nil {
139146
return ctrl.Result{}, err
@@ -229,6 +236,118 @@ func (r *CoderWorkspacePoolReconciler) deleteFailedMembers(
229236
return errors.Join(errs...)
230237
}
231238

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