Repository navigation
🤖 feat: bind CoderWorkspaceClaims to ready CoderWorkspacePool members and write the claim status #387
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
+670
−2
Merged
🤖 feat: bind CoderWorkspaceClaims to ready CoderWorkspacePool members and write the claim status #387
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
3f5e870
feat: bind CoderWorkspaceClaims to ready CoderWorkspacePool members a…
ThomasK33 c482469
fix: never bind a CoderWorkspaceClaim twice, scope its holder to its …
ThomasK33 c8c6095
fix: refresh waiting CoderWorkspaceClaims without the cache, skip a r…
ThomasK33 366c494
fix: bind only members whose spec.poolName names the pool
ThomasK33 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,244 @@ | ||
| package controller | ||
|
|
||
| import ( | ||
| "cmp" | ||
| "context" | ||
| "fmt" | ||
| "slices" | ||
| "time" | ||
|
|
||
| "k8s.io/apimachinery/pkg/api/equality" | ||
| apierrors "k8s.io/apimachinery/pkg/api/errors" | ||
| "k8s.io/apimachinery/pkg/api/meta" | ||
| metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" | ||
| "sigs.k8s.io/controller-runtime/pkg/client" | ||
| "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" | ||
|
|
||
| coderv1alpha1 "github.com/coder/coder-k8s/api/v1alpha1" | ||
| ) | ||
|
|
||
| // poolClaims lists the claims of the pool in its namespace, oldest first. | ||
| // The list can come from the cache: the member list, which decides the | ||
| // binds, is uncached. | ||
| func (r *CoderWorkspacePoolReconciler) poolClaims(ctx context.Context, pool *coderv1alpha1.CoderWorkspacePool) ([]coderv1alpha1.CoderWorkspaceClaim, error) { | ||
| list := &coderv1alpha1.CoderWorkspaceClaimList{} | ||
| if err := r.List(ctx, list, client.InNamespace(pool.Namespace)); err != nil { | ||
| return nil, fmt.Errorf("list workspace claims in namespace %s: %w", pool.Namespace, err) | ||
| } | ||
| claims := slices.DeleteFunc(list.Items, func(c coderv1alpha1.CoderWorkspaceClaim) bool { return c.Spec.PoolName != pool.Name }) | ||
| for i := range claims { | ||
| if claims[i].UID == "" { | ||
| return nil, fmt.Errorf("assertion failed: workspace claim %s/%s has no UID", claims[i].Namespace, claims[i].Name) | ||
| } | ||
| } | ||
| slices.SortStableFunc(claims, func(a, b coderv1alpha1.CoderWorkspaceClaim) int { | ||
| return cmp.Or(a.CreationTimestamp.Compare(b.CreationTimestamp.Time), cmp.Compare(a.Name, b.Name)) | ||
| }) | ||
| return claims, nil | ||
| } | ||
|
|
||
| // poolClaimHolder returns the member of the claim's pool that holds the | ||
| // claim, or nil. The member is the source of truth: after a crash between | ||
| // the bind and the claim status, the next reconcile finds the claim's | ||
| // member here. spec.poolName is immutable, so a member of another pool | ||
| // never counts. | ||
| func poolClaimHolder(claim *coderv1alpha1.CoderWorkspaceClaim, all []coderv1alpha1.CoderWorkspacePoolMember) *coderv1alpha1.CoderWorkspacePoolMember { | ||
| i := slices.IndexFunc(all, func(m coderv1alpha1.CoderWorkspacePoolMember) bool { | ||
| return m.Spec.ClaimRef != nil && m.Spec.ClaimRef.UID == string(claim.UID) && m.Spec.PoolName == claim.Spec.PoolName | ||
| }) | ||
| if i < 0 { | ||
| return nil | ||
| } | ||
| return &all[i] | ||
| } | ||
|
|
||
| // poolClaimEverBound reports a claim that a member held once. Such a claim | ||
| // never binds again: when its member is gone, it becomes Lost. | ||
| func poolClaimEverBound(claim *coderv1alpha1.CoderWorkspaceClaim) bool { | ||
| return claim.Status.BoundTime != nil || claim.Status.Phase == coderv1alpha1.CoderWorkspaceClaimPhaseBound || | ||
| claim.Status.Phase == coderv1alpha1.CoderWorkspaceClaimPhaseLost | ||
| } | ||
|
|
||
| // refreshWaitingClaims reads each claim that waits by the (cached) list | ||
| // again without the cache, so a claim whose Bound or Lost status the cache | ||
| // has not shown yet neither binds a second member nor counts as demand. A | ||
| // claim that is gone, or whose name a new claim took, leaves the list. | ||
| func (r *CoderWorkspacePoolReconciler) refreshWaitingClaims( | ||
| ctx context.Context, claims []coderv1alpha1.CoderWorkspaceClaim, all []coderv1alpha1.CoderWorkspacePoolMember, | ||
| ) ([]coderv1alpha1.CoderWorkspaceClaim, error) { | ||
| kept := claims[:0] | ||
| for _, claim := range claims { | ||
| if !claim.DeletionTimestamp.IsZero() || poolClaimEverBound(&claim) || poolClaimHolder(&claim, all) != nil { | ||
| kept = append(kept, claim) | ||
| continue | ||
| } | ||
| fresh := &coderv1alpha1.CoderWorkspaceClaim{} | ||
| if err := r.APIReader.Get(ctx, client.ObjectKeyFromObject(&claim), fresh); apierrors.IsNotFound(err) { | ||
| continue | ||
| } else if err != nil { | ||
| return nil, fmt.Errorf("get workspace claim %s/%s: %w", claim.Namespace, claim.Name, err) | ||
| } | ||
| if fresh.UID != claim.UID { | ||
| continue // A new claim with the same name, maybe of another pool. | ||
| } | ||
| kept = append(kept, *fresh) | ||
| } | ||
| return kept, nil | ||
| } | ||
|
|
||
| // bindClaims binds the pool's waiting claims, oldest first, to its ready | ||
| // unclaimed members (plan 6.3 step 5): current before outdated, then the | ||
| // oldest ready time. Ready means what the counts use: phase Ready, | ||
| // Ready=True, and not used. One Update per bind sets spec.claimRef and | ||
| // moves the controller reference from the pool to the claim, with the | ||
| // listed resourceVersion, so a member that changed after the list (for | ||
| // example, someone used it) answers 409 and stays. A conflict ends the | ||
| // reconcile. The bound members change in all, so the later steps see them | ||
| // bound. The claims come from refreshWaitingClaims. Before each bind, the | ||
| // pool stores ownerName in the claim status, so the name survives a crash | ||
| // between the bind and the status. It also sets the pool label again, in | ||
| // case it drifted. | ||
| func (r *CoderWorkspacePoolReconciler) bindClaims( | ||
| ctx context.Context, pool *coderv1alpha1.CoderWorkspacePool, claims []coderv1alpha1.CoderWorkspaceClaim, | ||
| all []coderv1alpha1.CoderWorkspacePoolMember, counts *poolMemberCounts, ownerName string, | ||
| ) error { | ||
| if ownerName == "" { | ||
| return fmt.Errorf("assertion failed: pool %s/%s binds claims without the owner's Coder username", pool.Namespace, pool.Name) | ||
| } | ||
| var free []*coderv1alpha1.CoderWorkspacePoolMember | ||
| for i := range all { | ||
| m := &all[i] | ||
| controller := metav1.GetControllerOf(m) | ||
| // spec.poolName is immutable and decides the holder, so a member that | ||
| // names another pool is never bound here. | ||
| if controller != nil && controller.UID == pool.UID && m.Spec.PoolName == pool.Name && m.Spec.ClaimRef == nil && m.DeletionTimestamp.IsZero() && | ||
| poolMemberReady(m) && !meta.IsStatusConditionTrue(m.Status.Conditions, coderv1alpha1.CoderWorkspacePoolMemberConditionUsed) { | ||
| free = append(free, m) | ||
| } | ||
| } | ||
| readyAt := func(m *coderv1alpha1.CoderWorkspacePoolMember) time.Time { | ||
| return cmp.Or(m.Status.ReadyTime, &m.CreationTimestamp).Time | ||
| } | ||
| slices.SortStableFunc(free, func(a, b *coderv1alpha1.CoderWorkspacePoolMember) int { | ||
| aOld, bOld := poolMemberInputMismatch(a, pool) != "", poolMemberInputMismatch(b, pool) != "" | ||
| if aOld != bOld { | ||
| if aOld { | ||
| return 1 | ||
| } | ||
| return -1 | ||
| } | ||
| return readyAt(a).Compare(readyAt(b)) | ||
| }) | ||
| for i := range claims { | ||
| claim := &claims[i] | ||
| if len(free) == 0 { | ||
| return nil | ||
| } | ||
| if !claim.DeletionTimestamp.IsZero() || poolClaimEverBound(claim) || poolClaimHolder(claim, all) != nil { | ||
| continue | ||
| } | ||
| if claim.Status.OwnerName != ownerName { | ||
| claim.Status.OwnerName = ownerName | ||
| if err := r.Status().Update(ctx, claim); err != nil { | ||
| return fmt.Errorf("update status of workspace claim %s/%s: %w", claim.Namespace, claim.Name, err) | ||
| } | ||
| } | ||
| m := free[0] | ||
| current := poolMemberInputMismatch(m, pool) == "" | ||
| bound := m.DeepCopy() | ||
| bound.Spec.ClaimRef = &coderv1alpha1.CoderWorkspaceClaimReference{Name: claim.Name, UID: string(claim.UID)} | ||
| bound.OwnerReferences = slices.DeleteFunc(bound.OwnerReferences, func(o metav1.OwnerReference) bool { return o.UID == pool.UID }) | ||
| if bound.Labels == nil { | ||
| bound.Labels = map[string]string{} | ||
| } | ||
| bound.Labels[coderv1alpha1.CoderWorkspacePoolSelectorLabel] = string(pool.UID) | ||
| if err := controllerutil.SetControllerReference(claim, bound, r.Scheme); err != nil { | ||
|
ThomasK33 marked this conversation as resolved.
|
||
| return fmt.Errorf("assertion failed: set claim %s as controller of member %s: %w", claim.Name, m.Name, err) | ||
| } | ||
| if err := r.Update(ctx, bound); err != nil { | ||
|
ThomasK33 marked this conversation as resolved.
|
||
| return fmt.Errorf("bind pool member %s/%s to claim %s: %w", m.Namespace, m.Name, claim.Name, err) | ||
| } | ||
| *m, free = *bound, free[1:] | ||
| counts.unclaimed-- | ||
| counts.ready-- | ||
| counts.bound++ | ||
| if current { | ||
| counts.current-- | ||
| counts.readyCurrent-- | ||
| } else { | ||
| counts.outdated-- | ||
| } | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| // writeClaimStatuses writes the status of every claim of the pool from the | ||
| // member that holds it (plan 6.3 step 10). ownerName is the owner's Coder | ||
| // username from this reconcile, or "" when the owner read did not run. | ||
| func (r *CoderWorkspacePoolReconciler) writeClaimStatuses( | ||
| ctx context.Context, pool *coderv1alpha1.CoderWorkspacePool, claims []coderv1alpha1.CoderWorkspaceClaim, | ||
| all []coderv1alpha1.CoderWorkspacePoolMember, ownerName string, | ||
| ) error { | ||
| now := metav1.NewTime(r.Clock.Now()) | ||
| for i := range claims { | ||
| claim := &claims[i] | ||
| if claim.Status.Phase == coderv1alpha1.CoderWorkspaceClaimPhaseLost { | ||
| continue // Final. | ||
| } | ||
| before := claim.Status.DeepCopy() | ||
| st := &claim.Status | ||
| ready, lost, reason, message := false, false, "WaitingForReadyWorkspace", fmt.Sprintf("CoderWorkspacePool %s has no ready workspace for this claim yet.", pool.Name) | ||
| m := poolClaimHolder(claim, all) | ||
| if m == nil && poolClaimEverBound(claim) { | ||
| // Lost is final, so check the holder on an uncached list first: | ||
| // another reconcile can have bound a member after this one's list. | ||
| fresh := &coderv1alpha1.CoderWorkspacePoolMemberList{} | ||
| if err := r.APIReader.List(ctx, fresh, client.InNamespace(claim.Namespace)); err != nil { | ||
| return fmt.Errorf("list pool members in namespace %s: %w", claim.Namespace, err) | ||
| } | ||
| if h := poolClaimHolder(claim, fresh.Items); h != nil { | ||
| m = h.DeepCopy() | ||
| } | ||
| } | ||
| switch { | ||
| case m == nil && poolClaimEverBound(claim): | ||
| st.Phase, lost = coderv1alpha1.CoderWorkspaceClaimPhaseLost, true | ||
| reason, message = "MemberDeleted", "The member that held this claim is gone. The claim never binds again." | ||
|
ThomasK33 marked this conversation as resolved.
|
||
| case m != nil: | ||
| st.Phase, st.MemberName = coderv1alpha1.CoderWorkspaceClaimPhaseBound, m.Name | ||
| st.WorkspaceID, st.WorkspaceName = m.Status.WorkspaceID, m.Status.WorkspaceName | ||
| st.OwnerName = cmp.Or(ownerName, st.OwnerName) | ||
|
ThomasK33 marked this conversation as resolved.
|
||
| if st.BoundTime == nil { | ||
| st.BoundTime = &now | ||
| } | ||
| ready = poolMemberReady(m) | ||
| reason, message = "WorkspaceReady", fmt.Sprintf("Workspace %s is ready.", m.Status.WorkspaceName) | ||
| if !ready { | ||
| reason, message = cmp.Or(m.Status.Reason, "WorkspaceNotReady"), truncateTemplateTestMessage(m.Status.Message) | ||
| } | ||
| default: | ||
| st.Phase = coderv1alpha1.CoderWorkspaceClaimPhasePending | ||
| } | ||
| st.Reason, st.Message = reason, message | ||
| for conditionType, ok := range map[string]bool{ | ||
| coderv1alpha1.CoderWorkspaceClaimConditionReady: ready, | ||
| coderv1alpha1.CoderWorkspaceClaimConditionReconciling: !ready && !lost, | ||
| coderv1alpha1.CoderWorkspaceClaimConditionStalled: lost, | ||
| } { | ||
| status := metav1.ConditionFalse | ||
| if ok { | ||
| status = metav1.ConditionTrue | ||
| } | ||
| meta.SetStatusCondition(&st.Conditions, metav1.Condition{ | ||
| Type: conditionType, Status: status, ObservedGeneration: claim.Generation, Reason: reason, Message: message, LastTransitionTime: now, | ||
| }) | ||
| } | ||
| if equality.Semantic.DeepEqual(before, st) { | ||
| continue | ||
| } | ||
| if err := r.Status().Update(ctx, claim); err != nil { | ||
| return fmt.Errorf("update status of workspace claim %s/%s: %w", claim.Namespace, claim.Name, err) | ||
| } | ||
| } | ||
| return nil | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.