Skip to content

feat: Add feature-gated pod deletion cost management controller - #2894

Merged
kubernetes-prow[bot] merged 104 commits into
kubernetes-sigs:mainfrom
nathangeology:feat/pod-deletion-cost-management
Oct 1, 2026
Merged

kubernetes-prow[bot] merged 104 commits into
kubernetes-sigs:mainfrom
nathangeology:feat/pod-deletion-cost-management

Conversation

@nathangeology

@nathangeology nathangeology commented Mar 5, 2026 •

Copy link
Copy Markdown
Contributor

Pod Deletion Cost Management Controller

RFC: #2935
Status: Implementation aligned with RFC.

What this PR does

Adds a feature-gated controller (PodDeletionCostManagement) that manages controller.kubernetes.io/pod-deletion-cost annotations on pods running on Karpenter-managed nodes. The controller ranks nodes by consolidation priority and annotates pods so that ReplicaSet scale-down concentrates deletions on nodes Karpenter wants to consolidate.

This PR also introduces karpenter.sh/disruption-cost as the user-facing annotation for steering Karpenter consolidation. Customers who today set controller.kubernetes.io/pod-deletion-cost to influence consolidation should migrate to the new annotation.

How it works

  1. Reconciles every 60 seconds. ConsolidationState timestamp comparison skips cycles when nothing changed (zero API writes in steady state).
  2. Partitions nodes into four groups:
    • Group A (disrupted + PDB-blocked): Gets MinInt32. Not counted against budget.
    • Group B (drifted): Bounded by NodePool's drift disruption budget.
    • Group C (normal): Bounded by NodePool's consolidation disruption budget.
    • Group D (do-not-disrupt / no-budget): Annotations cleared.
  3. Annotates pods on top 50 ranked nodes (hard cap per cycle).
  4. Writes controller.kubernetes.io/pod-deletion-cost directly. There is no overwrite-protection for customer-set PDC values: enabling the gate is the operator's opt-in to Karpenter managing that annotation. Customers steering consolidation are expected to be on karpenter.sh/disruption-cost by then.

Feature gate

--feature-gates=PodDeletionCostManagement=true

Gate ON: controller writes controller.kubernetes.io/pod-deletion-cost; Karpenter's consolidation scoring reads only karpenter.sh/disruption-cost.

Gate OFF (default): controller does not run; Karpenter's consolidation scoring reads karpenter.sh/disruption-cost first and falls back to controller.kubernetes.io/pod-deletion-cost if the new annotation is absent. This preserves current behavior during the migration window.

Key design decisions

  • Per-NodePool budget limiting: Group B bounded by drift budget, Group C by consolidation budget. Overflow goes to Group D.
  • MinInt32 for Group A: Disrupted+blocked nodes always get absolute lowest cost, not counted against any budget.
  • Group D clearing: Annotations removed (not set high). RS controller applies default behavior.
  • karpenter.sh/disruption-cost: User-facing annotation for consolidation steering. The auto-managed pod-deletion-cost is not interpreted as consolidation cost when the gate is on.
  • Forced cutover migration: RFC v4 anchors migration to the next Kubernetes minor release. No multi-annotation coexistence protocol.

What's included

  • ranking.go - Four-tier ranking with budget limiting
  • annotation.go - Pod annotation writes (no overwrite-protection per v4)
  • controller.go - Reconciler with ConsolidationState change detection, budget enforcement, 50-node cap
  • queue.go - Controller-runtime-backed fire-and-forget queue for per-pod annotation writes (modeled after terminator.Queue)
  • metrics.go - Prometheus metrics for ranking duration, pods updated, cycles skipped
  • events.go - Kubernetes events for ranking completion and failures
  • pkg/utils/disruption/disruption.go - karpenter.sh/disruption-cost annotation with feature-gate-aware EvictionCost precedence
  • Full unit test suite

Follow-up work (not in this PR)

  • Topology-aware ranking factors (Beta graduation criteria)
  • Cleanup-on-disable (Possible Beta deliverable)
  • Sparse sorting to save on annotations during re-ordering

AI Disclosure

I used an LLM to parallel program with me on this. The original controller (Pod Deletion Cost Management) and its earlier revisions were drafted collaboratively over multiple sessions.

By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.

@k8s-ci-robot k8s-ci-robot added the do-not-merge/work-in-progress Indicates that a PR should not merge because it is a work in progress. label Mar 5, 2026
@k8s-ci-robot

Copy link
Copy Markdown
Contributor

[APPROVALNOTIFIER] This PR is NOT APPROVED

This pull-request has been approved by: nathangeology
Once this PR has been reviewed and has the lgtm label, please assign jmdeal for approval. For more information see the Code Review Process.

The full list of commands accepted by this bot can be found here.

Details Needs approval from an approver in each of these files:

Approvers can indicate their approval by writing /approve in a comment
Approvers can cancel approval by writing /approve cancel in a comment

@k8s-ci-robot k8s-ci-robot added the needs-ok-to-test Indicates a PR that requires an org member to verify it is safe to test. label Mar 5, 2026
@k8s-ci-robot

Copy link
Copy Markdown
Contributor

Hi @nathangeology. Thanks for your PR.

I'm waiting for a kubernetes-sigs member to verify that this patch is reasonable to test. If it is, they should reply with /ok-to-test on its own line. Until that is done, I will not automatically test new commits in this PR, but the usual testing commands by org members will still work.

Tip

We noticed you've done this a few times! Consider joining the org to skip this step and gain /lgtm and other bot rights. We recommend asking approvers on your previous PRs to sponsor you.

Once the patch is verified, the new status will be reflected by the ok-to-test label.

I understand the commands that are listed here.

Details

Instructions for interacting with me using PR comments are available here. If you have questions or suggestions related to my behavior, please file an issue against the kubernetes-sigs/prow repository.

@k8s-ci-robot k8s-ci-robot added size/XXL Denotes a PR that changes 1000+ lines, ignoring generated files. cncf-cla: yes Indicates the PR's author has signed the CNCF CLA. labels Mar 5, 2026
nathangeology added a commit to nathangeology/karpenter-core that referenced this pull request Apr 17, 2026
nathangeology added a commit to nathangeology/karpenter-core that referenced this pull request Apr 17, 2026
nathangeology added a commit to nathangeology/karpenter-core that referenced this pull request Apr 17, 2026
nathangeology added a commit to nathangeology/karpenter-core that referenced this pull request Apr 17, 2026
…README, third-party detection, bounded labeling (kp-2iw)

Fix test compilation:
- Remove RankingStrategyRandom references (symbol removed)
- Use NewRankingEngine() with no args (API simplified)
- Compute BaseRank dynamically as -len(nodes) (not exported)
- Remove PodDeletionCostRankingStrategy from test setup (field removed)
- Add PodDeletionCostManagement to test FeatureGates

Remove orphaned IPVS files:
- Delete ipvs_steadystate.go referencing removed SteadyState labels
- Delete all ipvs_*_test.go files left from IPVS feature removal

Add third-party annotation conflict detection:
- Track lastAssignedValues per pod UID in AnnotationManager
- Detect when current value differs from what Karpenter last set
- Remove sentinel annotation on conflict, skip pod, emit warning event
- Clean up tracking map when pods are deleted

Add bounded node labeling with cleanup:
- Limit annotation updates to top 50 nodes per cycle
- Track previouslyLabeledNodes across reconciles
- Clean up pod annotations when nodes drop out of top-N

Add tests for new features:
- Third-party detection: value modified externally → sentinel removed
- Bounded labeling: nodes under limit → all annotated

Rewrite README to describe PodCount-only with three-tier drift ranking.
Remove all references to Random, LargestToSmallest, SmallestToLargest,
UnallocatedVCPUPerPodCost strategies.
@nathangeology
nathangeology force-pushed the feat/pod-deletion-cost-management branch from 44158c8 to 6c67abd Compare April 20, 2026 19:00
@nathangeology
nathangeology marked this pull request as ready for review April 20, 2026 20:16
@k8s-ci-robot k8s-ci-robot removed the do-not-merge/work-in-progress Indicates that a PR should not merge because it is a work in progress. label Apr 20, 2026
@k8s-ci-robot
k8s-ci-robot requested a review from mwielgus April 20, 2026 20:16
@nathangeology
nathangeology force-pushed the feat/pod-deletion-cost-management branch 3 times, most recently from 1e3924f to 5f4c236 Compare April 29, 2026 21:22
@linux-foundation-easycla

linux-foundation-easycla Bot commented May 8, 2026 •

Copy link
Copy Markdown

CLA Signed
The committers listed above are authorized under a signed CLA.

@k8s-ci-robot k8s-ci-robot added cncf-cla: no Indicates the PR's author has not signed the CNCF CLA. and removed cncf-cla: yes Indicates the PR's author has signed the CNCF CLA. labels May 8, 2026
@nathangeology
nathangeology force-pushed the feat/pod-deletion-cost-management branch 3 times, most recently from f12031f to b63cb9d Compare May 8, 2026 21:29
@k8s-ci-robot k8s-ci-robot added cncf-cla: yes Indicates the PR's author has signed the CNCF CLA. and removed cncf-cla: no Indicates the PR's author has not signed the CNCF CLA. labels May 8, 2026
@nathangeology
nathangeology force-pushed the feat/pod-deletion-cost-management branch from beec33f to 5835712 Compare May 12, 2026 17:08
@k8s-ci-robot k8s-ci-robot removed the cncf-cla: yes Indicates the PR's author has signed the CNCF CLA. label May 12, 2026
Remove comments that restate the code they sit above and keep the ones
carrying context the code cannot: rejected alternatives, cross-controller
coupling, and upstream constraints.

Deleted: the queueItem doc, nodePartition, isGoingAway, tryEnqueueNode,
nodeMutatesAnyPod, a duplicated MergeFromWithOptions note, and a duplicate
skip-cursor line. Trimmed the narration out of RankNodes, classifyNode,
applyPerNodePoolBudget, enqueueAnnotationWrites, sortBySavingsRatio, and
the pending-writes gauge, all of which restated their own bodies or Help
strings.

Fixed stale references that no longer name anything: fetchPDBs (the call is
pdb.NewLimits), NodeRank (the struct was removed), shouldSkipUnchanged and
UpdatePodDeletionCosts. Dropped line-number citations and a branch-local
commit SHA that will not survive a squash.

Repaired run-on comment sentences left behind when em-dashes were stripped.

Comments only; no behaviour change.
Resolves conflicts with kubernetes-sigs#3311 (node repair as voluntary disruption) and
kubernetes-sigs#3312 (terminate-first drift), both merged 2026-09-25.

Feature gates, pkg/operator/options/options.go and pkg/test/options.go:
TerminateFirstDrift and PodDeletionCostManagement are independent gates that
landed in the same struct, the same FEATURE_GATES default string, the same
DefaultFeatureGates literal and the same ParseFeatureGates block. Both kept,
TerminateFirstDrift first to match main, PodDeletionCostManagement appended.

Disruption budget accounting, pkg/controllers/disruption/helpers.go and
pkg/controllers/pod/deletioncost/ranking.go: kubernetes-sigs#3311 made the already-disrupting
count reason-dependent, excluding merely-NotReady nodes from the Unhealthy
budget. This branch had extracted the same loop into the reason-agnostic
NodePoolStats / NodePoolStatsFromNodes helpers, so git grafted kubernetes-sigs#3311's
reason check into a function with no reason in scope. Both helpers now take
a reason and forward it. BuildDisruptionBudgetMapping passes its own reason,
which reproduces kubernetes-sigs#3311 exactly. RankNodes computes stats and budget together
per reason instead of sharing one snapshot across Drifted and Underutilized.
}

// sortBySavingsRatio orders nodes by disruptionutils.SavingsRatio DESC with a
// node-name tie-break for determinism. Mirrors

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we decided we didn't want to pull out the actual function into a helper so theres one source of truth, yea?

Comment thread pkg/controllers/pod/deletioncost/controller.go
// gates reconciles at manager startup, so cache-not-synced does not occur in
// steady state.
func classifyNode(ctx context.Context, kubeClient client.Client, clk clock.Clock, node *state.StateNode, nodePoolMap map[string]*v1.NodePool, nodePoolToInstanceTypesMap map[string]map[string]*cloudprovider.InstanceType, pdbs pdb.Limits) nodePartition {
if isGoingAway(node) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we need a check here for node.Managed() and we do nothing in that case

return partitionNormal
}

// isInstanceTypeUnresolvable reports whether disruption.NewCandidate would

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't love a function that predicts what another function will do

nathangeology and others added 2 commits September 30, 2026 10:02
Co-authored-by: Derek Frank <dfrankmn@protonmail.com>
1d4fd31 accepted a review suggestion verbatim. The suggestion was
directional pseudocode and did not compile:

  controller.go:174:32: undefined: i
  controller.go:174:43: undefined: replicaset

Implement the intent. Only kube-controller-manager's ReplicaSet controller
reads corev1.PodDeletionCost, and it reads it on the pods it claims by
CONTROLLER owner reference. A pod that merely carries a non-controller
ReplicaSet reference is never ranked by it, so a write there has no effect.
Gate on metav1.GetControllerOfNoCopy rather than scanning every owner
reference.

Reuse the existing precedent for the kind check: scheduling's
defaultconstraints.go already declares
appsv1.SchemeGroupVersion.WithKind("ReplicaSet") for the same purpose,
so no new string literal.

Filter the pod slice once in tryEnqueueNode, before the nodeMutatesAnyPod
guard, so the guard, the enqueue loop and the per-cycle cap all read the
same set. Filtering at the Add site alone would let a node hosting only
non-ReplicaSet pods pass the guard, spend a maxNodesPerCycle slot and
inflate nodes_with_pending_annotation_writes while enqueueing nothing.

Tests: two specs covering the gate and the cap interaction. Existing specs
are unaffected because rsOwnedPod already sets Controller=true on an
apps/v1 ReplicaSet reference.

go build ./... clean. go vet clean. Package suite 67 passed, 0 failed.
@kubernetes-prow kubernetes-prow Bot added the needs-rebase Indicates a PR cannot be merged because it has merge conflicts with HEAD. label Sep 30, 2026
Six conflict hunks, all the same shape and all purely additive. Upstream
kubernetes-sigs#3342 added the TerminateFirstRepair feature gate; this branch added
PodDeletionCostManagement. Each site lists the gates, so adding one at each
end produced a whole-block conflict that gofmt realignment widened.

Resolution keeps both gates everywhere, upstream ordering preserved and
PodDeletionCostManagement appended last so TerminateFirstDrift and
TerminateFirstRepair stay adjacent:

  pkg/operator/options/options.go
    FeatureGates struct
    feature-gates flag default string and help text
    DefaultFeatureGates()
    ParseFeatureGates()
  pkg/test/options.go
    test.FeatureGates struct
    the lo.FromPtrOr block in Options()

No behavioural decision. The two gates do not interact.

Verification on the merge result:
  go build ./...                                      clean
  go vet  options, test, deletioncost                 clean
  gofmt -l on both resolved files                     clean
  go test ./pkg/operator/options/...                  ok
  go test ./pkg/controllers/pod/deletioncost/...       ok
  go test ./pkg/controllers/disruption/...            369 passed, 1 failed

The single disruption failure is Consolidation/DRA "reclaims only the
candidate's share of a shared device, leaving the live pod's share
(B, partial)" at consolidation_dra_test.go:283. It is pre-existing and not
from this merge: the same spec fails in isolation on pristine
upstream/main 8de44cf and on the pre-merge branch head e01575c. The file
is upstream's, added by kubernetes-sigs#3113, and this merge does not touch it.
@kubernetes-prow kubernetes-prow Bot removed the needs-rebase Indicates a PR cannot be merged because it has merge conflicts with HEAD. label Sep 30, 2026
nathangeology and others added 5 commits September 30, 2026 16:28
classifyNode calls isGoingAway before ValidateNodeDisruptable can reject
anything. isGoingAway reads StateNode.MarkedForDeletion, which is
markedForDeletion || Deleted(), and Deleted()'s second clause is

    in.Node != nil && in.NodeClaim == nil && !in.Node.DeletionTimestamp.IsZero()

StateNode.Managed() is exactly NodeClaim != nil, so that clause fires
precisely for a node Karpenter does not own that carries a deletion
timestamp. classifyNode returned partitionDisrupted for it, i.e. Group A.

Group A is enqueued directly via tryEnqueueNode(..., math.MinInt32, ...)
and does not pass through enqueueCapped, so the node was also exempt from
maxNodesPerCycle. Net effect: any node Karpenter does not own, being
deleted by anyone, got the deletion-cost annotation stamped on every one
of its pods with no per-cycle cap.

ValidateNodeDisruptable does reject NodeClaim == nil with "node isn't
managed by karpenter", but ordering means it never sees the node. Filter
at RankNodes' entry instead, so sortBySavingsRatio, classifyNode and the
budget computation all inherit the exclusion from one place.

Group D is not the right destination: it issues a cleanup write that
clears the annotation, which on a node Karpenter does not own would strip
a value some other actor set. Exclusion has to precede partitioning.

Budgets are unaffected. disruption.NodePoolStatsFromNodes already skips
!node.Managed(), and after the filter the slice is managed-only anyway.

The regression test drives Controller.Reconcile with an unmanaged node
(NodeClaim nil, Node non-nil, deletion timestamp set) hosting one
ReplicaSet-owned pod, and asserts the pod carries no annotation. Before
this change the spec fails with pod-deletion-cost=-2147483648 and the
ranking log reports disruptedNodes=1.

Verification:
  go build ./...                                    clean
  go vet ./pkg/controllers/pod/deletioncost/...     clean
  go test ./pkg/controllers/pod/deletioncost/...    ok, 68 passed 1 pending
  new spec                                          fails before, passes after
Comment-only pass. Added // comment lines across this PR's changed files go
from 674 to 209, measured as diff-added lines whose trimmed text starts with
// against merge-base 8de44cf. Builds on 6b33468d from the earlier pass and
extends it to controller.go (38 -> 22) and controller_test.go (108 -> 39),
which that pass deliberately skipped to avoid colliding with in-flight work.

A comment survived only if it carries something the code cannot: an external
contract (the ReplicaSet controller is the only in-tree reader of
pod-deletion-cost; it evicts the lowest value first), a refactor-hostile
invariant (lo.GroupBy preserves per-partition order; applyPerNodePoolBudget
needs a pre-sorted slice; MergeFromWithOptions captures by pointer), a
deliberate absence (why there is no feature-gate spec, why the abort-all
shape is not asserted for healthy NodePools), a magic-number justification
(2^25 priority band, maxNodesPerCycle against the RFC's 1,500-write target),
or regression provenance. Restatements of the next line are gone.

Left intact: Apache headers, the gate-semantics docs on EvictionCost and
DisruptionCostAnnotationKey, the drift-dedupe TODO, the PENDING queue-defect
note, and the comment on the unmanaged-node filter in RankNodes.

Pre-existing upstream comments that only appear in the diff because a
function moved or a symbol was renamed are untouched: BuildDisruptionBudgets
in disruption/helpers.go, the offering-price note in disruption/types.go, and
the disruptionCost-range pair in utils/disruption.

Verification:
  comments-only proof   go/scanner mode 0 token streams identical per file,
                        directive comments unchanged (see commit body note)
  gofmt -l pkg/         clean
  go build ./...        clean
  go vet ./pkg/...      clean
  go test               deletioncost ok
…eable

The old name and doc comment justified the Group D route by predicting what
disruption.NewCandidate would reject. The prediction was wrong for one branch.
NewCandidate's only instance-type rejection is a nil NodePool or a nil
instance-type map (types.go:178); the instance-type name itself is a bare map
lookup (types.go:183), so a missing name leaves instanceType nil and the
candidate is still built with Price 0.

Calling NewCandidate instead of predicting it is not available here. It
publishes user-visible Blocked events on every rejection path (types.go:164,
179, 193), and it calls node.ValidatePodsDisruptable, which
classifyDisruptableNode has already called.

So the justification is now local and makes no claim about another function. A
node with no resolvable instance type gets Price 0 from ResolveOfferingPrice,
and groupBC is ordered by savings ratio, so its position in that ordering
carries no information. It routes to partitionCleanupOnly, where rank is
unused. "unpriceable" is the word already used for this state at
pkg/controllers/disruption/types.go:370.

No behaviour change. Both branches still return true and the nodes still route
to partitionCleanupOnly. Neither branch was covered, so this adds a test that
pins them, with a priced control node to show the filter is selective: a node
whose instance-type label is absent from its NodePool's map, and a node whose
NodePool is absent from the instance-type map, must both land in Group D.
isUnpriceable now has full statement coverage; package coverage 93.9% to 94.7%.
…s/pod

The "is this pod ReplicaSet-controlled" determination was written twice.
isControlledByReplicaSet in controller.go did it properly, via
metav1.GetControllerOfNoCopy compared against apps/v1 ReplicaSet.
hasNonRSOwnedPods in ranking.go did it again and worse, comparing owner.Kind to
the bare string "ReplicaSet" inside a loop over every owner reference with no
APIVersion check, so a CRD defining its own ReplicaSet kind would have been
mistaken for apps/v1.

pkg/utils/pod already had the any-owner-reference primitive, IsOwnedBy, with
IsOwnedByStatefulSet, IsOwnedByDaemonSet and IsOwnedByNode composing it. It had
no controller-reference equivalent, which is why controller.go hand-rolled one.
This adds IsControlledBy beside IsOwnedBy, and IsControlledByReplicaSet beside
the other wrappers. Both call sites now compose their own question from the
primitive, and neither walks owner references itself.

The two sites still ask different questions and so still carry different kind
sets. The enqueue gate asks whether the ReplicaSet controller will read the
annotation, over {ReplicaSet}, because kube-controller-manager's ReplicaSet
controller is the only in-tree consumer of pod-deletion-cost. Ranking asks
whether any pod pins the node, over {ReplicaSet, Job, DaemonSet}, because all
three of those controllers replace a deleted pod elsewhere. A node of
all-DaemonSet pods is therefore ranked and enqueues nothing, and both answers
are right for their own question. hasNonRSOwnedPods is renamed hasPinningPods,
because it tolerated Job and DaemonSet and the old name said otherwise.

BEHAVIOUR CHANGE, deliberate, and not a side effect of the extraction. The
pinning check now reads the controller reference instead of any owner reference.
A pod whose only ReplicaSet reference is not its controller reference used to
count as ReplicaSet-owned and did not pin its node. It now pins it, the same as
a bare pod. Only the controller replaces a deleted pod, so a non-controller
reference does not make a pod recreatable, and a pod that no controller claims
sits in exactly the position this function already treated as pinning when there
were no owner references at all. The old any-ref read was wrong in both
directions: it failed to pin a pod nothing controls, and it pinned a node on
account of a pod whose controller is a ReplicaSet but which carries some
unrelated extra reference.

The existing controller spec asserted the old behaviour through its
nonControllerRSPod fixture. That fixture moves to its own spec asserting the new
behaviour, with a control node proving the route is caused by the non-controller
reference. The original spec keeps the Job pod that was already carrying its
load-bearing case. IsControlledBy and hasPinningPods both have full statement
coverage.

@DerekFrank DerekFrank left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/lgtm

@kubernetes-prow kubernetes-prow Bot added the lgtm "Looks good to me", indicates that a PR is ready to be merged. label Oct 1, 2026
@DerekFrank
DerekFrank enabled auto-merge (squash) October 1, 2026 22:51
@kubernetes-prow

Copy link
Copy Markdown

[APPROVALNOTIFIER] This PR is APPROVED

This pull-request has been approved by: DerekFrank, nathangeology

The full list of commands accepted by this bot can be found here.

The pull request process is described here

Details Needs approval from an approver in each of these files:

Approvers can indicate their approval by writing /approve in a comment
Approvers can cancel approval by writing /approve cancel in a comment

@kubernetes-prow kubernetes-prow Bot added the approved Indicates a PR has been approved by an approver from all required OWNERS files. label Oct 1, 2026
@kubernetes-prow
kubernetes-prow Bot merged commit 283777e into kubernetes-sigs:main Oct 1, 2026
16 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

approved Indicates a PR has been approved by an approver from all required OWNERS files. cncf-cla: yes Indicates the PR's author has signed the CNCF CLA. lgtm "Looks good to me", indicates that a PR is ready to be merged. needs-ok-to-test Indicates a PR that requires an org member to verify it is safe to test. size/XXL Denotes a PR that changes 1000+ lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants