Repository: kubernetes-sigs/kwok
Pull request #1067 (2024-05-07 09:39:37)

Refactor Stage implementation
<!--  Thanks for sending a pull request!  Here are some tips for you:

1. If this is your first time, please read our contributor guidelines: https://git.k8s.io/community/contributors/guide/first-contribution.md#your-first-contribution and developer guide https://git.k8s.io/community/contributors/devel/development.md#development-guide
2. Please label this pull request according to what type of issue you are addressing, especially if this is a release targeted pull request. For reference on required PR/issue labels, read here:
https://git.k8s.io/community/contributors/devel/sig-release/release.md#issuepr-kind-label
3. Ensure you have added or ran the appropriate tests for your PR: https://git.k8s.io/community/contributors/devel/sig-testing/testing.md
4. If you want *faster* PR reviews, read how: https://git.k8s.io/community/contributors/guide/pull-requests.md#best-practices-for-faster-reviews
5. If the PR is unfinished, see how to mark it: https://git.k8s.io/community/contributors/guide/pull-requests.md#marking-unfinished-pull-requests
-->

#### What type of PR is this?

<!--
Add one of the following kinds:
/kind bug
/kind cleanup
/kind documentation
/kind feature

Optionally add one or more of the following kinds if applicable:
/kind api-change
/kind deprecation
/kind failing-test
/kind flake
/kind regression
-->

/kind cleanup

#### What this PR does / why we need it:

#### Which issue(s) this PR fixes:
<!--
*Automatically closes linked issue when PR is merged.
Usage: `Fixes #<issue number>`, or `Fixes (paste link of issue)`.
_If PR is about `failing-tests or flakes`, please post the related issues/tests in a comment and do not use `Fixes`_*
-->
Fixes #

#### Special notes for your reviewer:

#### Does this PR introduce a user-facing change?
<!--
If no, just write "NONE" in the release-note block below.
If yes, a release note is required:
Enter your extended release note in the block below. If the PR requires additional action from users switching to the new release, include the string "action required".

For more information on release notes see: https://git.k8s.io/community/contributors/guide/release-notes.md
-->
```release-note
NONE
```

#### Additional documentation e.g., KEPs (Kubernetes Enhancement Proposals), usage docs, etc.:

<!--
This section can be blank if this pull request does not require a release note.

When adding links which point to resources within git repositories, like
KEPs or supporting documentation, please reference a specific commit and avoid
linking directly to the master branch. This ensures that links reference a
specific point in time, rather than a document that may change over time.

See here for guidance on getting permanent links to files: https://help.github.com/en/articles/getting-permanent-links-to-files

Please use the following format for linking documentation:
- [KEP]: <link>
- [Usage]: <link>
- [Other doc]: <link>
-->
```docs
NONE
```


netlify[bot]: ### <span aria-hidden="true">👷</span> Deploy Preview for *k8s-kwok* processing.


|  Name | Link |
|:-:|------------------------|
|<span aria-hidden="true">🔨</span> Latest commit | 48262139d286f8ed631430132261fd2a15622c1a |
|<span aria-hidden="true">🔍</span> Latest deploy log | https://app.netlify.com/sites/k8s-kwok/deploys/6639f6db67913e000867fbd5 |
k8s-ci-robot: @wzshiming: The following test **failed**, say `/retest` to rerun all failed tests or `/retest-required` to rerun all mandatory failed tests:

Test name | Commit | Details | Required | Rerun command
--- | --- | --- | --- | ---
pull-kwok-unit-test-main | 48262139d286f8ed631430132261fd2a15622c1a | [link](https://prow.k8s.io/view/gs/kubernetes-jenkins/pr-logs/pull/kubernetes-sigs_kwok/1067/pull-kwok-unit-test-main/1787779318756151296) | true | `/test pull-kwok-unit-test-main`

[Full PR test history](https://prow.k8s.io/pr-history?org=kubernetes-sigs&repo=kwok&pr=1067). [Your PR dashboard](https://prow.k8s.io/pr?query=is%3Apr%20state%3Aopen%20author%3Awzshiming). Please help us cut down on flakes by [linking to](https://git.k8s.io/community/contributors/devel/sig-testing/flaky-tests.md#filing-issues-for-flaky-tests) an [open issue](https://github.com/kubernetes-sigs/kwok/issues?q=is:issue+is:open) when you hit one in your PR.

<details>

Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [kubernetes-sigs/prow](https://github.com/kubernetes-sigs/prow/issues/new?title=Prow%20issue:) repository. I understand the commands that are listed [here](https://go.k8s.io/bot-commands).
</details>
<!-- test report -->
k8s-ci-robot: @wzshiming: The following test **failed**, say `/retest` to rerun all failed tests or `/retest-required` to rerun all mandatory failed tests:

Test name | Commit | Details | Required | Rerun command
--- | --- | --- | --- | ---
pull-kwok-unit-test-main | ca1fc7941c45aaf065cce2c9be7f95f03a733043 | [link](https://prow.k8s.io/view/gs/kubernetes-jenkins/pr-logs/pull/kubernetes-sigs_kwok/1067/pull-kwok-unit-test-main/1787786550604468224) | true | `/test pull-kwok-unit-test-main`

[Full PR test history](https://prow.k8s.io/pr-history?org=kubernetes-sigs&repo=kwok&pr=1067). [Your PR dashboard](https://prow.k8s.io/pr?query=is%3Apr%20state%3Aopen%20author%3Awzshiming). Please help us cut down on flakes by [linking to](https://git.k8s.io/community/contributors/devel/sig-testing/flaky-tests.md#filing-issues-for-flaky-tests) an [open issue](https://github.com/kubernetes-sigs/kwok/issues?q=is:issue+is:open) when you hit one in your PR.

<details>

Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [kubernetes-sigs/prow](https://github.com/kubernetes-sigs/prow/issues/new?title=Prow%20issue:) repository. I understand the commands that are listed [here](https://go.k8s.io/bot-commands).
</details>
<!-- test report -->
k8s-ci-robot: @wzshiming: The following tests **failed**, say `/retest` to rerun all failed tests or `/retest-required` to rerun all mandatory failed tests:

Test name | Commit | Details | Required | Rerun command
--- | --- | --- | --- | ---
pull-kwok-unit-test-main | ca1fc7941c45aaf065cce2c9be7f95f03a733043 | [link](https://prow.k8s.io/view/gs/kubernetes-jenkins/pr-logs/pull/kubernetes-sigs_kwok/1067/pull-kwok-unit-test-main/1787786550604468224) | true | `/test pull-kwok-unit-test-main`
pull-kwok-verify-main | ca1fc7941c45aaf065cce2c9be7f95f03a733043 | [link](https://prow.k8s.io/view/gs/kubernetes-jenkins/pr-logs/pull/kubernetes-sigs_kwok/1067/pull-kwok-verify-main/1787786550503804928) | true | `/test pull-kwok-verify-main`

[Full PR test history](https://prow.k8s.io/pr-history?org=kubernetes-sigs&repo=kwok&pr=1067). [Your PR dashboard](https://prow.k8s.io/pr?query=is%3Apr%20state%3Aopen%20author%3Awzshiming). Please help us cut down on flakes by [linking to](https://git.k8s.io/community/contributors/devel/sig-testing/flaky-tests.md#filing-issues-for-flaky-tests) an [open issue](https://github.com/kubernetes-sigs/kwok/issues?q=is:issue+is:open) when you hit one in your PR.

<details>

Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [kubernetes-sigs/prow](https://github.com/kubernetes-sigs/prow/issues/new?title=Prow%20issue:) repository. I understand the commands that are listed [here](https://go.k8s.io/bot-commands).
</details>
<!-- test report -->
k8s-ci-robot: @wzshiming: The following tests **failed**, say `/retest` to rerun all failed tests or `/retest-required` to rerun all mandatory failed tests:

Test name | Commit | Details | Required | Rerun command
--- | --- | --- | --- | ---
pull-kwok-verify-main | ca1fc7941c45aaf065cce2c9be7f95f03a733043 | [link](https://prow.k8s.io/view/gs/kubernetes-jenkins/pr-logs/pull/kubernetes-sigs_kwok/1067/pull-kwok-verify-main/1787786550503804928) | true | `/test pull-kwok-verify-main`
pull-kwok-unit-test-main | ca1fc7941c45aaf065cce2c9be7f95f03a733043 | [link](https://prow.k8s.io/view/gs/kubernetes-jenkins/pr-logs/pull/kubernetes-sigs_kwok/1067/pull-kwok-unit-test-main/1787792056601546752) | true | `/test pull-kwok-unit-test-main`

[Full PR test history](https://prow.k8s.io/pr-history?org=kubernetes-sigs&repo=kwok&pr=1067). [Your PR dashboard](https://prow.k8s.io/pr?query=is%3Apr%20state%3Aopen%20author%3Awzshiming). Please help us cut down on flakes by [linking to](https://git.k8s.io/community/contributors/devel/sig-testing/flaky-tests.md#filing-issues-for-flaky-tests) an [open issue](https://github.com/kubernetes-sigs/kwok/issues?q=is:issue+is:open) when you hit one in your PR.

<details>

Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [kubernetes-sigs/prow](https://github.com/kubernetes-sigs/prow/issues/new?title=Prow%20issue:) repository. I understand the commands that are listed [here](https://go.k8s.io/bot-commands).
</details>
<!-- test report -->
k8s-ci-robot: @wzshiming: The following test **failed**, say `/retest` to rerun all failed tests or `/retest-required` to rerun all mandatory failed tests:

Test name | Commit | Details | Required | Rerun command
--- | --- | --- | --- | ---
pull-kwok-verify-main | 8e428c1698a6d0b8596e98f5dc45a177f8e9ff4f | [link](https://prow.k8s.io/view/gs/kubernetes-jenkins/pr-logs/pull/kubernetes-sigs_kwok/1067/pull-kwok-verify-main/1787794252139335680) | true | `/test pull-kwok-verify-main`

[Full PR test history](https://prow.k8s.io/pr-history?org=kubernetes-sigs&repo=kwok&pr=1067). [Your PR dashboard](https://prow.k8s.io/pr?query=is%3Apr%20state%3Aopen%20author%3Awzshiming). Please help us cut down on flakes by [linking to](https://git.k8s.io/community/contributors/devel/sig-testing/flaky-tests.md#filing-issues-for-flaky-tests) an [open issue](https://github.com/kubernetes-sigs/kwok/issues?q=is:issue+is:open) when you hit one in your PR.

<details>

Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [kubernetes-sigs/prow](https://github.com/kubernetes-sigs/prow/issues/new?title=Prow%20issue:) repository. I understand the commands that are listed [here](https://go.k8s.io/bot-commands).
</details>
<!-- test report -->
yuanchen8911: /retest

Patch:
diff --git a/Makefile b/Makefile
index 1700f01d..007c9797 100644
--- a/Makefile
+++ b/Makefile
@@ -255,8 +255,7 @@ help:
 
 .PRECIOUS: %.cast
 %.cast: %.demo
-	@WORK_DIR=$(shell dirname $<) \
-	./hack/democtl.sh "$<" "$@" \
+	@./hack/democtl.sh "$<" "$@" \
 		--ps1='\033[1;96m~/sigs.k8s.io/kwok\033[1;94m$$\033[0m '
 
 .PRECIOUS: %.svg
diff --git a/pkg/kwok/controllers/controller.go b/pkg/kwok/controllers/controller.go
index 7b7c0d02..abb0785c 100644
--- a/pkg/kwok/controllers/controller.go
+++ b/pkg/kwok/controllers/controller.go
@@ -50,6 +50,7 @@ import (
 	"sigs.k8s.io/kwok/pkg/utils/client"
 	"sigs.k8s.io/kwok/pkg/utils/gotpl"
 	"sigs.k8s.io/kwok/pkg/utils/informer"
+	"sigs.k8s.io/kwok/pkg/utils/lifecycle"
 	"sigs.k8s.io/kwok/pkg/utils/patch"
 	"sigs.k8s.io/kwok/pkg/utils/queue"
 	"sigs.k8s.io/kwok/pkg/utils/slices"
@@ -374,7 +375,7 @@ func (c *Controller) nodeLeaseSyncWorker(ctx context.Context) {
 var podRef = internalversion.StageResourceRef{APIGroup: "v1", Kind: "Pod"}
 var nodeRef = internalversion.StageResourceRef{APIGroup: "v1", Kind: "Node"}
 
-func (c *Controller) startStageController(ctx context.Context, ref internalversion.StageResourceRef, lifecycle resources.Getter[Lifecycle]) error {
+func (c *Controller) startStageController(ctx context.Context, ref internalversion.StageResourceRef, lifecycle resources.Getter[lifecycle.Lifecycle]) error {
 	switch ref {
 	case podRef:
 		err := c.initPodController(ctx, lifecycle)
@@ -457,7 +458,7 @@ func (c *Controller) onNodeUnmanaged(nodeName string) {
 	c.onNodeUnmanagedFunc(nodeName)
 }
 
-func (c *Controller) initNodeController(ctx context.Context, lifecycle resources.Getter[Lifecycle]) (err error) {
+func (c *Controller) initNodeController(ctx context.Context, lifecycle resources.Getter[lifecycle.Lifecycle]) (err error) {
 	c.nodes, err = NewNodeController(NodeControllerConfig{
 		Clock:                                 c.conf.Clock,
 		TypedClient:                           c.conf.TypedClient,
@@ -485,7 +486,7 @@ func (c *Controller) initNodeController(ctx context.Context, lifecycle resources
 
 	return nil
 }
-func (c *Controller) initPodController(ctx context.Context, lifecycle resources.Getter[Lifecycle]) (err error) {
+func (c *Controller) initPodController(ctx context.Context, lifecycle resources.Getter[lifecycle.Lifecycle]) (err error) {
 	c.pods, err = NewPodController(PodControllerConfig{
 		Clock:                                 c.conf.Clock,
 		EnableCNI:                             c.conf.EnableCNI,
@@ -521,7 +522,7 @@ func (c *Controller) initPodController(ctx context.Context, lifecycle resources.
 	return nil
 }
 
-func (c *Controller) initStageController(ctx context.Context, ref internalversion.StageResourceRef, lifecycle resources.Getter[Lifecycle]) error {
+func (c *Controller) initStageController(ctx context.Context, ref internalversion.StageResourceRef, lifecycle resources.Getter[lifecycle.Lifecycle]) error {
 	logger := log.FromContext(ctx)
 
 	gv, err := schema.ParseGroupVersion(ref.APIGroup)
@@ -581,7 +582,7 @@ func (c *Controller) Start(ctx context.Context) error {
 
 	if len(c.conf.LocalStages) != 0 {
 		for ref, stage := range c.conf.LocalStages {
-			lifecycle, err := NewLifecycle(stage)
+			lifecycle, err := lifecycle.NewLifecycle(stage)
 			if err != nil {
 				return err
 			}
diff --git a/pkg/kwok/controllers/node_controller.go b/pkg/kwok/controllers/node_controller.go
index af91344a..370700c8 100644
--- a/pkg/kwok/controllers/node_controller.go
+++ b/pkg/kwok/controllers/node_controller.go
@@ -18,7 +18,6 @@ package controllers
 
 import (
 	"context"
-	"encoding/json"
 	"fmt"
 	"net"
 	"sync/atomic"
@@ -27,19 +26,17 @@ import (
 	corev1 "k8s.io/api/core/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/labels"
-	"k8s.io/apimachinery/pkg/types"
 	"k8s.io/client-go/kubernetes"
 	"k8s.io/client-go/tools/record"
 	"k8s.io/utils/clock"
 	netutils "k8s.io/utils/net"
 
-	"sigs.k8s.io/kwok/pkg/apis/internalversion"
 	"sigs.k8s.io/kwok/pkg/config/resources"
 	"sigs.k8s.io/kwok/pkg/log"
 	"sigs.k8s.io/kwok/pkg/utils/expression"
-	"sigs.k8s.io/kwok/pkg/utils/format"
 	"sigs.k8s.io/kwok/pkg/utils/gotpl"
 	"sigs.k8s.io/kwok/pkg/utils/informer"
+	"sigs.k8s.io/kwok/pkg/utils/lifecycle"
 	"sigs.k8s.io/kwok/pkg/utils/maps"
 	"sigs.k8s.io/kwok/pkg/utils/queue"
 	"sigs.k8s.io/kwok/pkg/utils/wait"
@@ -97,7 +94,7 @@ type NodeController struct {
 	renderer                              gotpl.Renderer
 	preprocessChan                        chan *corev1.Node
 	playStageParallelism                  uint
-	lifecycle                             resources.Getter[Lifecycle]
+	lifecycle                             resources.Getter[lifecycle.Lifecycle]
 	delayQueue                            queue.WeightDelayingQueue[resourceStageJob[*corev1.Node]]
 	delayQueueMapping                     maps.SyncMap[string, resourceStageJob[*corev1.Node]]
 	backoff                               wait.Backoff
@@ -117,7 +114,7 @@ type NodeControllerConfig struct {
 	NodeIP                                string
 	NodeName                              string
 	NodePort                              int
-	Lifecycle                             resources.Getter[Lifecycle]
+	Lifecycle                             resources.Getter[lifecycle.Lifecycle]
 	PlayStageParallelism                  uint
 	FuncMap                               gotpl.FuncMap
 	Recorder                              record.EventRecorder
@@ -266,30 +263,6 @@ loop:
 	logger.Info("Stop watch nodes")
 }
 
-// finalizersModify modifies the finalizers of a node
-func (c *NodeController) finalizersModify(ctx context.Context, node *corev1.Node, finalizers *internalversion.StageFinalizers) (*corev1.Node, error) {
-	ops := finalizersModify(node.Finalizers, finalizers)
-	if len(ops) == 0 {
-		return nil, nil
-	}
-	data, err := json.Marshal(ops)
-	if err != nil {
-		return nil, err
-	}
-
-	logger := log.FromContext(ctx)
-	logger = logger.With(
-		"node", node.Name,
-	)
-
-	result, err := c.typedClient.CoreV1().Nodes().Patch(ctx, node.Name, types.JSONPatchType, data, metav1.PatchOptions{})
-	if err != nil {
-		return nil, err
-	}
-	logger.Info("Patch node finalizers")
-	return result, nil
-}
-
 // deleteResource deletes a node
 func (c *NodeController) deleteResource(ctx context.Context, node *corev1.Node) error {
 	logger := log.FromContext(ctx)
@@ -350,8 +323,8 @@ func (c *NodeController) preprocess(ctx context.Context, node *corev1.Node) erro
 		return err
 	}
 
-	lifecycle := c.lifecycle.Get()
-	stage, err := lifecycle.Match(node.Labels, node.Annotations, data)
+	lc := c.lifecycle.Get()
+	stage, err := lc.Match(node.Labels, node.Annotations, data)
 	if err != nil {
 		return fmt.Errorf("stage match: %w", err)
 	}
@@ -419,7 +392,7 @@ func (c *NodeController) playStageWorker(ctx context.Context) {
 
 // playStage plays the stage.
 // The returned boolean indicates whether the applying action needs to be retried.
-func (c *NodeController) playStage(ctx context.Context, node *corev1.Node, stage *LifecycleStage) (bool, error) {
+func (c *NodeController) playStage(ctx context.Context, node *corev1.Node, stage *lifecycle.Stage) (bool, error) {
 	next := stage.Next()
 	logger := log.FromContext(ctx)
 	logger = logger.With(
@@ -432,40 +405,49 @@ func (c *NodeController) playStage(ctx context.Context, node *corev1.Node, stage
 		err    error
 	)
 
-	if next.Event != nil && c.recorder != nil {
+	if event := next.Event(); event != nil && c.recorder != nil {
 		c.recorder.Event(&corev1.ObjectReference{
 			Kind:      "Node",
 			UID:       node.UID,
 			Name:      node.Name,
 			Namespace: "",
-		}, next.Event.Type, next.Event.Reason, next.Event.Message)
+		}, event.Type, event.Reason, event.Message)
 	}
 
-	if next.Finalizers != nil {
-		result, err = c.finalizersModify(ctx, node, next.Finalizers)
+	patch, err := next.Finalizers(node.Finalizers)
+	if err != nil {
+		return false, fmt.Errorf("failed to get finalizers for node %s: %w", node.Name, err)
+	}
+	if patch != nil {
+		result, err = c.patchResource(ctx, node, patch)
 		if err != nil {
 			return shouldRetry(err), fmt.Errorf("failed to patch the finalizer of node %s: %w", node.Name, err)
 		}
 	}
 
-	if next.Delete {
+	if next.Delete() {
 		err = c.deleteResource(ctx, node)
 		if err != nil {
 			return shouldRetry(err), fmt.Errorf("failed to delete node %s: %w", node.Name, err)
 		}
 		result = nil
-	} else if len(next.Patches) != 0 {
-		for _, patch := range next.Patches {
-			patchData, patchType, err := c.computePatch(node, patch)
+	} else {
+		patches, err := next.Patches(node, c.renderer)
+		if err != nil {
+			return false, fmt.Errorf("failed to get patches for node %s: %w", node.Name, err)
+		}
+
+		for _, patch := range patches {
+			changed, err := checkNeedPatchWithTyped(node, patch.Data, patch.Type)
 			if err != nil {
-				return shouldRetry(err), fmt.Errorf("failed to compute the node %s: %w", node.Name, err)
+				return false, fmt.Errorf("failed to check need patch for node %s: %w", node.Name, err)
 			}
-			if patchData == nil {
+			if !changed {
 				logger.Debug("Skip node",
 					"reason", "do not need to modify",
 				)
 			} else {
-				result, err = c.patchResource(ctx, node, patchData, patchType, patch)
+				result, err = c.patchResource(ctx, node, patch)
 				if err != nil {
 					return shouldRetry(err), fmt.Errorf("failed to patch node %s: %w", node.Name, err)
 				}
@@ -489,7 +471,7 @@ func (c *NodeController) readOnly(nodeName string) bool {
 }
 
 // patchResource patches the resource
-func (c *NodeController) patchResource(ctx context.Context, node *corev1.Node, patchData []byte, patchType types.PatchType, patch internalversion.StagePatch) (*corev1.Node, error) {
+func (c *NodeController) patchResource(ctx context.Context, node *corev1.Node, patch *lifecycle.Patch) (*corev1.Node, error) {
 	logger := log.FromContext(ctx)
 	logger = logger.With(
 		"node", node.Name,
@@ -502,7 +484,7 @@ func (c *NodeController) patchResource(ctx context.Context, node *corev1.Node, p
 		)
 		subresource = []string{patch.Subresource}
 	}
-	result, err := c.typedClient.CoreV1().Nodes().Patch(ctx, node.Name, patchType, patchData, metav1.PatchOptions{}, subresource...)
+	result, err := c.typedClient.CoreV1().Nodes().Patch(ctx, node.Name, patch.Type, patch.Data, metav1.PatchOptions{}, subresource...)
 	if err != nil {
 		return nil, err
 	}
@@ -510,131 +492,6 @@ func (c *NodeController) patchResource(ctx context.Context, node *corev1.Node, p
 	return result, nil
 }
 
-func (c *NodeController) computePatch(node *corev1.Node, patch internalversion.StagePatch) ([]byte, types.PatchType, error) {
-	switch format.ElemOrDefault(patch.Type) {
-	case internalversion.StagePatchTypeJSONPatch:
-		patchData, err := c.computeJSONPatch(node, patch.Root, patch.Template)
-		if err != nil {
-			return nil, "", err
-		}
-		return patchData, types.JSONPatchType, nil
-	case internalversion.StagePatchTypeStrategicMergePatch, "":
-		patchData, err := c.computeStrategicMergePatch(node, patch.Root, patch.Template)
-		if err != nil {
-			return nil, "", err
-		}
-		return patchData, types.StrategicMergePatchType, nil
-	case internalversion.StagePatchTypeMergePatch:
-		patchData, err := c.computeMergePatch(node, patch.Root, patch.Template)
-		if err != nil {
-			return nil, "", err
-		}
-		return patchData, types.MergePatchType, nil
-	}
-
-	return nil, "", fmt.Errorf("unknown patch type %s", *patch.Type)
-}
-
-func (c *NodeController) computeStrategicMergePatch(node *corev1.Node, root, tpl string) ([]byte, error) {
-	patchData, err := c.renderer.ToJSON(tpl, node)
-	if err != nil {
-		return nil, err
-	}
-
-	var hasChange bool
-	switch root {
-	default:
-		return nil, fmt.Errorf("root %q is not supported", root)
-	case "":
-		hasChange, err = checkNeedStrategicMergePatch(*node, patchData)
-		if err != nil {
-			return nil, err
-		}
-	case "metadata":
-		hasChange, err = checkNeedStrategicMergePatch(node.ObjectMeta, patchData)
-		if err != nil {
-			return nil, err
-		}
-	case "spec":
-		hasChange, err = checkNeedStrategicMergePatch(node.Spec, patchData)
-		if err != nil {
-			return nil, err
-		}
-	case "status":
-		hasChange, err = checkNeedStrategicMergePatch(node.Status, patchData)
-		if err != nil {
-			return nil, err
-		}
-	}
-
-	if !hasChange {
-		return nil, nil
-	}
-
-	return wrapMergePatchData(root, patchData)
-}
-
-func (c *NodeController) computeMergePatch(node *corev1.Node, root, tpl string) ([]byte, error) {
-	patchData, err := c.renderer.ToJSON(tpl, node)
-	if err != nil {
-		return nil, err
-	}
-
-	var hasChange bool
-	switch root {
-	default:
-		return nil, fmt.Errorf("root %q is not supported", root)
-	case "":
-		hasChange, err = checkNeedMergePatch(*node, patchData)
-		if err != nil {
-			return nil, err
-		}
-	case "metadata":
-		hasChange, err = checkNeedMergePatch(node.ObjectMeta, patchData)
-		if err != nil {
-			return nil, err
-		}
-	case "spec":
-		hasChange, err = checkNeedMergePatch(node.Spec, patchData)
-		if err != nil {
-			return nil, err
-		}
-	case "status":
-		hasChange, err = checkNeedMergePatch(node.Status, patchData)
-		if err != nil {
-			return nil, err
-		}
-	}
-
-	if !hasChange {
-		return nil, nil
-	}
-
-	return wrapMergePatchData(root, patchData)
-}
-
-func (c *NodeController) computeJSONPatch(node *corev1.Node, root, tpl string) ([]byte, error) {
-	patchData, err := c.renderer.ToJSON(tpl, node)
-	if err != nil {
-		return nil, err
-	}
-
-	patchData, err = wrapJSONPatchData(root, patchData)
-	if err != nil {
-		return nil, err
-	}
-
-	hasChange, err := checkNeedJSONPatch(*node, patchData)
-	if err != nil {
-		return nil, err
-	}
-	if !hasChange {
-		return nil, nil
-	}
-
-	return patchData, nil
-}
-
 // putNodeInfo puts node info
 func (c *NodeController) putNodeInfo(node *corev1.Node) {
 	c.nodesSets.Store(node.Name, &NodeInfo{})
diff --git a/pkg/kwok/controllers/pod_controller.go b/pkg/kwok/controllers/pod_controller.go
index b30bec4b..482636a6 100644
--- a/pkg/kwok/controllers/pod_controller.go
+++ b/pkg/kwok/controllers/pod_controller.go
@@ -18,7 +18,6 @@ package controllers
 
 import (
 	"context"
-	"encoding/json"
 	"fmt"
 	"sync/atomic"
 	"time"
@@ -26,19 +25,17 @@ import (
 	corev1 "k8s.io/api/core/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/labels"
-	"k8s.io/apimachinery/pkg/types"
 	"k8s.io/client-go/kubernetes"
 	"k8s.io/client-go/tools/record"
 	"k8s.io/utils/clock"
 
-	"sigs.k8s.io/kwok/pkg/apis/internalversion"
 	"sigs.k8s.io/kwok/pkg/config/resources"
 	"sigs.k8s.io/kwok/pkg/kwok/cni"
 	"sigs.k8s.io/kwok/pkg/log"
 	"sigs.k8s.io/kwok/pkg/utils/expression"
-	"sigs.k8s.io/kwok/pkg/utils/format"
 	"sigs.k8s.io/kwok/pkg/utils/gotpl"
 	"sigs.k8s.io/kwok/pkg/utils/informer"
+	"sigs.k8s.io/kwok/pkg/utils/lifecycle"
 	"sigs.k8s.io/kwok/pkg/utils/maps"
 	"sigs.k8s.io/kwok/pkg/utils/queue"
 	"sigs.k8s.io/kwok/pkg/utils/wait"
@@ -65,7 +62,7 @@ type PodController struct {
 	podsOnNode                            maps.SyncMap[string, *maps.SyncMap[log.ObjectRef, *PodInfo]]
 	preprocessChan                        chan *corev1.Pod
 	playStageParallelism                  uint
-	lifecycle                             resources.Getter[Lifecycle]
+	lifecycle                             resources.Getter[lifecycle.Lifecycle]
 	delayQueue                            queue.WeightDelayingQueue[resourceStageJob[*corev1.Pod]]
 	backoff                               wait.Backoff
 	delayQueueMapping                     maps.SyncMap[string, resourceStageJob[*corev1.Pod]]
@@ -90,7 +87,7 @@ type PodControllerConfig struct {
 	CIDR                                  string
 	NodeGetFunc                           func(nodeName string) (*NodeInfo, bool)
 	NodeHasMetric                         func(nodeName string) bool
-	Lifecycle                             resources.Getter[Lifecycle]
+	Lifecycle                             resources.Getter[lifecycle.Lifecycle]
 	PlayStageParallelism                  uint
 	FuncMap                               gotpl.FuncMap
 	Recorder                              record.EventRecorder
@@ -158,31 +155,6 @@ func (c *PodController) Start(ctx context.Context, events <-chan informer.Event[
 	return nil
 }
 
-// finalizersModify modifies the finalizers of a pod
-func (c *PodController) finalizersModify(ctx context.Context, pod *corev1.Pod, finalizers *internalversion.StageFinalizers) (*corev1.Pod, error) {
-	ops := finalizersModify(pod.Finalizers, finalizers)
-	if len(ops) == 0 {
-		return nil, nil
-	}
-	data, err := json.Marshal(ops)
-	if err != nil {
-		return nil, err
-	}
-
-	logger := log.FromContext(ctx)
-	logger = logger.With(
-		"pod", log.KObj(pod),
-		"node", pod.Spec.NodeName,
-	)
-
-	result, err := c.typedClient.CoreV1().Pods(pod.Namespace).Patch(ctx, pod.Name, types.JSONPatchType, data, metav1.PatchOptions{})
-	if err != nil {
-		return nil, err
-	}
-	logger.Info("Patch pod finalizers")
-	return result, nil
-}
-
 // deleteResource deletes a pod
 func (c *PodController) deleteResource(ctx context.Context, pod *corev1.Pod) error {
 	logger := log.FromContext(ctx)
@@ -246,8 +218,8 @@ func (c *PodController) preprocess(ctx context.Context, pod *corev1.Pod) error {
 		return err
 	}
 
-	lifecycle := c.lifecycle.Get()
-	stage, err := lifecycle.Match(pod.Labels, pod.Annotations, data)
+	lc := c.lifecycle.Get()
+	stage, err := lc.Match(pod.Labels, pod.Annotations, data)
 	if err != nil {
 		return fmt.Errorf("stage match: %w", err)
 	}
@@ -315,7 +287,7 @@ func (c *PodController) playStageWorker(ctx context.Context) {
 
 // playStage plays the stage.
 // The returned boolean indicates whether the applying action needs to be retried.
-func (c *PodController) playStage(ctx context.Context, pod *corev1.Pod, stage *LifecycleStage) (bool, error) {
+func (c *PodController) playStage(ctx context.Context, pod *corev1.Pod, stage *lifecycle.Stage) (bool, error) {
 	next := stage.Next()
 	logger := log.FromContext(ctx)
 	logger = logger.With(
@@ -329,41 +301,49 @@ func (c *PodController) playStage(ctx context.Context, pod *corev1.Pod, stage *L
 		err    error
 	)
 
-	if next.Event != nil && c.recorder != nil {
+	if event := next.Event(); event != nil && c.recorder != nil {
 		c.recorder.Event(&corev1.ObjectReference{
 			Kind:      "Pod",
 			UID:       pod.UID,
 			Name:      pod.Name,
 			Namespace: pod.Namespace,
-		}, next.Event.Type, next.Event.Reason, next.Event.Message)
+		}, event.Type, event.Reason, event.Message)
 	}
 
-	if next.Finalizers != nil {
-		result, err = c.finalizersModify(ctx, pod, next.Finalizers)
+	patch, err := next.Finalizers(pod.Finalizers)
+	if err != nil {
+		return false, fmt.Errorf("failed to get finalizers for pod %s: %w", pod.Name, err)
+	}
+	if patch != nil {
+		result, err = c.patchResource(ctx, pod, patch)
 		if err != nil {
 			return shouldRetry(err), fmt.Errorf("failed to patch the finalizer of pod %s: %w", pod.Name, err)
 		}
 	}
 
-	if next.Delete {
+	if next.Delete() {
 		err = c.deleteResource(ctx, pod)
 		if err != nil {
 			return shouldRetry(err), fmt.Errorf("failed to delete pod %s: %w", pod.Name, err)
 		}
 		result = nil
-	} else if len(next.Patches) != 0 {
+	} else {
 		c.markPodIP(pod)
-		for _, patch := range next.Patches {
-			patchData, patchType, err := c.computePatch(pod, patch)
+		patches, err := next.Patches(pod, c.renderer)
+		if err != nil {
+			return false, fmt.Errorf("failed to get patches for pod %s: %w", pod.Name, err)
+		}
+		for _, patch := range patches {
+			changed, err := checkNeedPatchWithTyped(pod, patch.Data, patch.Type)
 			if err != nil {
-				return shouldRetry(err), fmt.Errorf("failed to compute the pod %s: %w", pod.Name, err)
+				return false, fmt.Errorf("failed to check need patch for pod %s: %w", pod.Name, err)
 			}
-			if patchData == nil {
+			if !changed {
 				logger.Debug("Skip pod",
 					"reason", "do not need to modify",
 				)
 			} else {
-				result, err = c.patchResource(ctx, pod, patchData, patchType, patch)
+				result, err = c.patchResource(ctx, pod, patch)
 				if err != nil {
 					return shouldRetry(err), fmt.Errorf("failed to patch pod %s: %w", pod.Name, err)
 				}
@@ -387,7 +367,7 @@ func (c *PodController) readOnly(nodeName string) bool {
 }
 
 // patchResource patches the resource
-func (c *PodController) patchResource(ctx context.Context, pod *corev1.Pod, patchData []byte, patchType types.PatchType, patch internalversion.StagePatch) (*corev1.Pod, error) {
+func (c *PodController) patchResource(ctx context.Context, pod *corev1.Pod, patch *lifecycle.Patch) (*corev1.Pod, error) {
 	logger := log.FromContext(ctx)
 	logger = logger.With(
 		"pod", log.KObj(pod),
@@ -401,7 +381,7 @@ func (c *PodController) patchResource(ctx context.Context, pod *corev1.Pod, patc
 		)
 		subresource = []string{patch.Subresource}
 	}
-	result, err := c.typedClient.CoreV1().Pods(pod.Namespace).Patch(ctx, pod.Name, patchType, patchData, metav1.PatchOptions{}, subresource...)
+	result, err := c.typedClient.CoreV1().Pods(pod.Namespace).Patch(ctx, pod.Name, patch.Type, patch.Data, metav1.PatchOptions{}, subresource...)
 	if err != nil {
 		return nil, err
 	}
@@ -576,131 +556,6 @@ func (c *PodController) markPodIP(pod *corev1.Pod) {
 	}
 }
 
-func (c *PodController) computePatch(pod *corev1.Pod, patch internalversion.StagePatch) ([]byte, types.PatchType, error) {
-	switch format.ElemOrDefault(patch.Type) {
-	case internalversion.StagePatchTypeJSONPatch:
-		patchData, err := c.computeJSONPatch(pod, patch.Root, patch.Template)
-		if err != nil {
-			return nil, "", err
-		}
-		return patchData, types.JSONPatchType, nil
-	case internalversion.StagePatchTypeStrategicMergePatch, "":
-		patchData, err := c.computeStrategicMergePatch(pod, patch.Root, patch.Template)
-		if err != nil {
-			return nil, "", err
-		}
-		return patchData, types.StrategicMergePatchType, nil
-	case internalversion.StagePatchTypeMergePatch:
-		patchData, err := c.computeMergePatch(pod, patch.Root, patch.Template)
-		if err != nil {
-			return nil, "", err
-		}
-		return patchData, types.MergePatchType, nil
-	}
-
-	return nil, "", fmt.Errorf("unknown patch type %s", *patch.Type)
-}
-
-func (c *PodController) computeStrategicMergePatch(pod *corev1.Pod, root, tpl string) ([]byte, error) {
-	patchData, err := c.renderer.ToJSON(tpl, pod)
-	if err != nil {
-		return nil, err
-	}
-
-	var hasChange bool
-	switch root {
-	default:
-		return nil, fmt.Errorf("root %q is not supported", root)
-	case "":
-		hasChange, err = checkNeedStrategicMergePatch(*pod, patchData)
-		if err != nil {
-			return nil, err
-		}
-	case "metadata":
-		hasChange, err = checkNeedStrategicMergePatch(pod.ObjectMeta, patchData)
-		if err != nil {
-			return nil, err
-		}
-	case "spec":
-		hasChange, err = checkNeedStrategicMergePatch(pod.Spec, patchData)
-		if err != nil {
-			return nil, err
-		}
-	case "status":
-		hasChange, err = checkNeedStrategicMergePatch(pod.Status, patchData)
-		if err != nil {
-			return nil, err
-		}
-	}
-
-	if !hasChange {
-		return nil, nil
-	}
-
-	return wrapMergePatchData(root, patchData)
-}
-
-func (c *PodController) computeMergePatch(pod *corev1.Pod, root, tpl string) ([]byte, error) {
-	patchData, err := c.renderer.ToJSON(tpl, pod)
-	if err != nil {
-		return nil, err
-	}
-
-	var hasChange bool
-	switch root {
-	default:
-		return nil, fmt.Errorf("root %q is not supported", root)
-	case "":
-		hasChange, err = checkNeedMergePatch(*pod, patchData)
-		if err != nil {
-			return nil, err
-		}
-	case "metadata":
-		hasChange, err = checkNeedMergePatch(pod.ObjectMeta, patchData)
-		if err != nil {
-			return nil, err
-		}
-	case "spec":
-		hasChange, err = checkNeedMergePatch(pod.Spec, patchData)
-		if err != nil {
-			return nil, err
-		}
-	case "status":
-		hasChange, err = checkNeedMergePatch(pod.Status, patchData)
-		if err != nil {
-			return nil, err
-		}
-	}
-
-	if !hasChange {
-		return nil, nil
-	}
-
-	return wrapMergePatchData(root, patchData)
-}
-
-func (c *PodController) computeJSONPatch(pod *corev1.Pod, root, tpl string) ([]byte, error) {
-	patchData, err := c.renderer.ToJSON(tpl, pod)
-	if err != nil {
-		return nil, err
-	}
-
-	patchData, err = wrapJSONPatchData(root, patchData)
-	if err != nil {
-		return nil, err
-	}
-
-	hasChange, err := checkNeedJSONPatch(*pod, patchData)
-	if err != nil {
-		return nil, err
-	}
-	if !hasChange {
-		return nil, nil
-	}
-
-	return patchData, nil
-}
-
 func (c *PodController) funcNodeIP() string {
 	return c.nodeIP
 }
diff --git a/pkg/kwok/controllers/stage_controller.go b/pkg/kwok/controllers/stage_controller.go
index 116a374e..7246db5b 100644
--- a/pkg/kwok/controllers/stage_controller.go
+++ b/pkg/kwok/controllers/stage_controller.go
@@ -17,34 +17,29 @@ limitations under the License.
 package controllers
 
 import (
-	"bytes"
 	"context"
-	"encoding/json"
 	"fmt"
 	"sync/atomic"
 	"time"
 
-	jsonpatch "github.com/evanphx/json-patch/v5"
 	corev1 "k8s.io/api/core/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
 	"k8s.io/apimachinery/pkg/labels"
 	"k8s.io/apimachinery/pkg/runtime/schema"
-	"k8s.io/apimachinery/pkg/types"
 	"k8s.io/apimachinery/pkg/util/strategicpatch"
 	"k8s.io/client-go/dynamic"
 	"k8s.io/client-go/rest"
 	"k8s.io/client-go/tools/record"
 	"k8s.io/utils/clock"
 
-	"sigs.k8s.io/kwok/pkg/apis/internalversion"
 	"sigs.k8s.io/kwok/pkg/config/resources"
 	"sigs.k8s.io/kwok/pkg/log"
 	"sigs.k8s.io/kwok/pkg/utils/client"
 	"sigs.k8s.io/kwok/pkg/utils/expression"
-	"sigs.k8s.io/kwok/pkg/utils/format"
 	"sigs.k8s.io/kwok/pkg/utils/gotpl"
 	"sigs.k8s.io/kwok/pkg/utils/informer"
+	"sigs.k8s.io/kwok/pkg/utils/lifecycle"
 	"sigs.k8s.io/kwok/pkg/utils/maps"
 	"sigs.k8s.io/kwok/pkg/utils/queue"
 	"sigs.k8s.io/kwok/pkg/utils/wait"
@@ -62,7 +57,7 @@ type StageController struct {
 	renderer                              gotpl.Renderer
 	preprocessChan                        chan *unstructured.Unstructured
 	playStageParallelism                  uint
-	lifecycle                             resources.Getter[Lifecycle]
+	lifecycle                             resources.Getter[lifecycle.Lifecycle]
 	delayQueue                            queue.WeightDelayingQueue[resourceStageJob[*unstructured.Unstructured]]
 	backoff                               wait.Backoff
 	delayQueueMapping                     maps.SyncMap[string, resourceStageJob[*unstructured.Unstructured]]
@@ -78,7 +73,7 @@ type StageControllerConfig struct {
 	GVR                                   schema.GroupVersionResource
 	DisregardStatusWithAnnotationSelector string
 	DisregardStatusWithLabelSelector      string
-	Lifecycle                             resources.Getter[Lifecycle]
+	Lifecycle                             resources.Getter[lifecycle.Lifecycle]
 	PlayStageParallelism                  uint
 	FuncMap                               gotpl.FuncMap
 	Recorder                              record.EventRecorder
@@ -135,35 +130,6 @@ func (c *StageController) Start(ctx context.Context, events <-chan informer.Even
 	return nil
 }
 
-// finalizersModify modifies the finalizers of a resource
-func (c *StageController) finalizersModify(ctx context.Context, resource *unstructured.Unstructured, finalizers *internalversion.StageFinalizers) (*unstructured.Unstructured, error) {
-	ops := finalizersModify(resource.GetFinalizers(), finalizers)
-	if len(ops) == 0 {
-		return nil, nil
-	}
-	data, err := json.Marshal(ops)
-	if err != nil {
-		return nil, err
-	}
-
-	logger := log.FromContext(ctx)
-	logger = logger.With(
-		"resource", log.KObj(resource),
-	)
-
-	nri := c.dynamicClient.Resource(c.gvr)
-	var cli dynamic.ResourceInterface = nri
-	if ns := resource.GetNamespace(); ns != "" {
-		cli = nri.Namespace(ns)
-	}
-	result, err := cli.Patch(ctx, resource.GetName(), types.JSONPatchType, data, metav1.PatchOptions{})
-	if err != nil {
-		return nil, err
-	}
-	logger.Info("Patch resource finalizers")
-	return result, nil
-}
-
 // deleteResource deletes a resource
 func (c *StageController) deleteResource(ctx context.Context, resource *unstructured.Unstructured) error {
 	logger := log.FromContext(ctx)
@@ -229,8 +195,8 @@ func (c *StageController) preprocess(ctx context.Context, resource *unstructured
 		return err
 	}
 
-	lifecycle := c.lifecycle.Get()
-	stage, err := lifecycle.Match(resource.GetLabels(), resource.GetAnnotations(), data)
+	lc := c.lifecycle.Get()
+	stage, err := lc.Match(resource.GetLabels(), resource.GetAnnotations(), data)
 	if err != nil {
 		return fmt.Errorf("stage match: %w", err)
 	}
@@ -299,7 +265,7 @@ func (c *StageController) playStageWorker(ctx context.Context) {
 
 // playStage plays the stage.
 // The returned boolean indicates whether the applying action needs to be retried.
-func (c *StageController) playStage(ctx context.Context, resource *unstructured.Unstructured, stage *LifecycleStage) (bool, error) {
+func (c *StageController) playStage(ctx context.Context, resource *unstructured.Unstructured, stage *lifecycle.Stage) (bool, error) {
 	next := stage.Next()
 	logger := log.FromContext(ctx)
 	logger = logger.With(
@@ -312,41 +278,50 @@ func (c *StageController) playStage(ctx context.Context, resource *unstructured.
 		err    error
 	)
 
-	if next.Event != nil && c.recorder != nil {
+	if event := next.Event(); event != nil && c.recorder != nil {
 		c.recorder.Event(&corev1.ObjectReference{
 			APIVersion: resource.GetAPIVersion(),
 			Kind:       resource.GetKind(),
 			UID:        resource.GetUID(),
 			Name:       resource.GetName(),
 			Namespace:  resource.GetNamespace(),
-		}, next.Event.Type, next.Event.Reason, next.Event.Message)
+		}, event.Type, event.Reason, event.Message)
 	}
 
-	if next.Finalizers != nil {
-		result, err = c.finalizersModify(ctx, resource, next.Finalizers)
+	patch, err := next.Finalizers(resource.GetFinalizers())
+	if err != nil {
+		return false, fmt.Errorf("failed to get finalizers for resource %s: %w", resource.GetName(), err)
+	}
+	if patch != nil {
+		result, err = c.patchResource(ctx, resource, patch)
 		if err != nil {
 			return shouldRetry(err), fmt.Errorf("failed to patch the finalizer of resource %s: %w", resource.GetName(), err)
 		}
 	}
 
-	if next.Delete {
+	if next.Delete() {
 		err = c.deleteResource(ctx, resource)
 		if err != nil {
 			return shouldRetry(err), fmt.Errorf("failed to delete resource %s: %w", resource.GetName(), err)
 		}
 		result = nil
-	} else if len(next.Patches) != 0 {
-		for _, patch := range next.Patches {
-			patchData, patchType, err := c.computePatch(resource, patch)
+	} else {
+		patches, err := next.Patches(resource.Object, c.renderer)
+		if err != nil {
+			return false, fmt.Errorf("failed to get patches for resource %s: %w", resource.GetName(), err)
+		}
+		for _, patch := range patches {
+			changed, err := checkNeedPatch(resource.Object, patch.Data, patch.Type, c.schema)
 			if err != nil {
-				return shouldRetry(err), fmt.Errorf("failed to compute resource %s: %w", resource.GetName(), err)
+				return false, fmt.Errorf("failed to check need patch for resource %s: %w", resource.GetName(), err)
 			}
-			if patchData == nil {
+
+			if !changed {
 				logger.Debug("Skip resource",
 					"reason", "do not need to modify",
 				)
 			} else {
-				result, err = c.patchResource(ctx, resource, patchData, patchType, patch)
+				result, err = c.patchResource(ctx, resource, patch)
 				if err != nil {
 					return shouldRetry(err), fmt.Errorf("failed to patch resource %s: %w", resource.GetName(), err)
 				}
@@ -363,7 +338,7 @@ func (c *StageController) playStage(ctx context.Context, resource *unstructured.
 }
 
 // patchResource patches the resource
-func (c *StageController) patchResource(ctx context.Context, resource *unstructured.Unstructured, patchData []byte, patchType types.PatchType, patch internalversion.StagePatch) (*unstructured.Unstructured, error) {
+func (c *StageController) patchResource(ctx context.Context, resource *unstructured.Unstructured, patch *lifecycle.Patch) (*unstructured.Unstructured, error) {
 	logger := log.FromContext(ctx)
 	logger = logger.With(
 		"resource", log.KObj(resource),
@@ -394,7 +369,7 @@ func (c *StageController) patchResource(ctx context.Context, resource *unstructu
 		subresource = []string{patch.Subresource}
 	}
 
-	result, err := cli.Patch(ctx, resource.GetName(), patchType, patchData, metav1.PatchOptions{}, subresource...)
+	result, err := cli.Patch(ctx, resource.GetName(), patch.Type, patch.Data, metav1.PatchOptions{}, subresource...)
 	if err != nil {
 		return nil, err
 	}
@@ -459,140 +434,6 @@ loop:
 	logger.Info("Stop watch resources")
 }
 
-func (c *StageController) computePatch(resource *unstructured.Unstructured, patch internalversion.StagePatch) ([]byte, types.PatchType, error) {
-	switch format.ElemOrDefault(patch.Type) {
-	case internalversion.StagePatchTypeJSONPatch:
-		patchData, err := c.computeJSONPatch(resource, patch.Root, patch.Template)
-		if err != nil {
-			return nil, "", err
-		}
-		return patchData, types.JSONPatchType, nil
-	case internalversion.StagePatchTypeStrategicMergePatch:
-		patchData, err := c.computeStrategicMergePatch(resource, patch.Root, patch.Template)
-		if err != nil {
-			return nil, "", err
-		}
-		return patchData, types.StrategicMergePatchType, nil
-	case internalversion.StagePatchTypeMergePatch, "":
-		patchData, err := c.computeMergePatch(resource, patch.Root, patch.Template)
-		if err != nil {
-			return nil, "", err
-		}
-		return patchData, types.MergePatchType, nil
-	}
-
-	return nil, "", fmt.Errorf("unknown patch type %s", *patch.Type)
-}
-
-func (c *StageController) computeMergePatch(resource *unstructured.Unstructured, root, tpl string) ([]byte, error) {
-	patchData, err := c.renderer.ToJSON(tpl, resource.Object)
-	if err != nil {
-		return nil, err
-	}
-
-	var base any = resource.Object
-
-	if root != "" {
-		base, _, err = unstructured.NestedFieldNoCopy(resource.Object, root)
-		if err != nil {
-			return nil, err
-		}
-	}
-
-	if base != nil {
-		original, err := json.Marshal(base)
-		if err != nil {
-			return nil, err
-		}
-
-		sum, err := jsonpatch.MergePatch(original, patchData)
-		if err != nil {
-			return nil, err
-		}
-
-		if bytes.Equal(sum, original) {
-			return nil, nil
-		}
-	}
-
-	return wrapMergePatchData(root, patchData)
-}
-
-func (c *StageController) computeStrategicMergePatch(resource *unstructured.Unstructured, root, tpl string) ([]byte, error) {
-	patchData, err := c.renderer.ToJSON(tpl, resource.Object)
-	if err != nil {
-		return nil, err
-	}
-
-	if c.schema != nil {
-		var base any = resource.Object
-		var patchMeta = c.schema
-
-		if root != "" {
-			base, _, err = unstructured.NestedFieldNoCopy(resource.Object, root)
-			if err != nil {
-				return nil, err
-			}
-
-			patchMeta, _, err = c.schema.LookupPatchMetadataForStruct(root)
-			if err != nil {
-				return nil, err
-			}
-		}
-
-		if base != nil {
-			original, err := json.Marshal(base)
-			if err != nil {
-				return nil, err
-			}
-
-			sum, err := strategicpatch.StrategicMergePatchUsingLookupPatchMeta(original, patchData, patchMeta)
-			if err != nil {
-				return nil, err
-			}
-
-			if bytes.Equal(sum, original) {
-				return nil, nil
-			}
-		}
-	}
-
-	return wrapMergePatchData(root, patchData)
-}
-
-func (c *StageController) computeJSONPatch(resource *unstructured.Unstructured, root, tpl string) ([]byte, error) {
-	patchData, err := c.renderer.ToJSON(tpl, resource.Object)
-	if err != nil {
-		return nil, err
-	}
-
-	patchData, err = wrapJSONPatchData(root, patchData)
-	if err != nil {
-		return nil, err
-	}
-
-	patch, err := jsonpatch.DecodePatch(patchData)
-	if err != nil {
-		return nil, err
-	}
-
-	original, err := json.Marshal(resource.Object)
-	if err != nil {
-		return nil, err
-	}
-
-	sum, err := patch.Apply(original)
-	if err != nil {
-		return nil, err
-	}
-
-	if bytes.Equal(sum, original) {
-		return nil, nil
-	}
-
-	return patchData, nil
-}
-
 // addStageJob adds a stage to be applied into the underlying weight delay queue and the associated helper map
 func (c *StageController) addStageJob(ctx context.Context, job resourceStageJob[*unstructured.Unstructured], delay time.Duration, weight int) {
 	old, loaded := c.delayQueueMapping.Swap(job.Key, job)
diff --git a/pkg/kwok/controllers/stages_manager.go b/pkg/kwok/controllers/stages_manager.go
index 90fef95b..b61978c6 100644
--- a/pkg/kwok/controllers/stages_manager.go
+++ b/pkg/kwok/controllers/stages_manager.go
@@ -22,6 +22,7 @@ import (
 	"sigs.k8s.io/kwok/pkg/apis/internalversion"
 	"sigs.k8s.io/kwok/pkg/config/resources"
 	"sigs.k8s.io/kwok/pkg/log"
+	"sigs.k8s.io/kwok/pkg/utils/lifecycle"
 	"sigs.k8s.io/kwok/pkg/utils/sets"
 	"sigs.k8s.io/kwok/pkg/utils/slices"
 )
@@ -29,14 +30,14 @@ import (
 // StagesManagerConfig is the configuration for a stages manager
 type StagesManagerConfig struct {
 	StageGetter resources.DynamicGetter[[]*internalversion.Stage]
-	StartFunc   func(ctx context.Context, ref internalversion.StageResourceRef, lifecycle resources.Getter[Lifecycle]) error
+	StartFunc   func(ctx context.Context, ref internalversion.StageResourceRef, lifecycle resources.Getter[lifecycle.Lifecycle]) error
 }
 
 // StagesManager is a stages manager
 // It is a dynamic getter for stages and start a stage controller
 type StagesManager struct {
 	stageGetter resources.DynamicGetter[[]*internalversion.Stage]
-	startFunc   func(ctx context.Context, ref internalversion.StageResourceRef, lifecycle resources.Getter[Lifecycle]) error
+	startFunc   func(ctx context.Context, ref internalversion.StageResourceRef, lifecycle resources.Getter[lifecycle.Lifecycle]) error
 	cache       map[internalversion.StageResourceRef]context.CancelCauseFunc
 }
 
@@ -83,13 +84,13 @@ func (c *StagesManager) manage(ctx context.Context) {
 			continue
 		}
 
-		lifecycle := resources.NewFilter[Lifecycle, []*internalversion.Stage](c.stageGetter, func(stages []*internalversion.Stage) Lifecycle {
-			return slices.FilterAndMap(stages, func(stage *internalversion.Stage) (*LifecycleStage, bool) {
+		lifecycle := resources.NewFilter[lifecycle.Lifecycle, []*internalversion.Stage](c.stageGetter, func(stages []*internalversion.Stage) lifecycle.Lifecycle {
+			return slices.FilterAndMap(stages, func(stage *internalversion.Stage) (*lifecycle.Stage, bool) {
 				if stage.Spec.ResourceRef != ref {
 					return nil, false
 				}
 
-				lifecycleStage, err := NewLifecycleStage(stage)
+				lifecycleStage, err := lifecycle.NewStage(stage)
 				if err != nil {
 					logger.Error("failed to create lifecycle stage", err, "ref", ref)
 					return nil, false
diff --git a/pkg/kwok/controllers/utils.go b/pkg/kwok/controllers/utils.go
index cd68e80d..8e1835b0 100644
--- a/pkg/kwok/controllers/utils.go
+++ b/pkg/kwok/controllers/utils.go
@@ -19,6 +19,7 @@ package controllers
 import (
 	"bytes"
 	"encoding/json"
+	"fmt"
 	"math"
 	"net"
 	"sync"
@@ -27,9 +28,11 @@ import (
 	jsonpatch "github.com/evanphx/json-patch/v5"
 	apierrors "k8s.io/apimachinery/pkg/api/errors"
 	"k8s.io/apimachinery/pkg/labels"
+	"k8s.io/apimachinery/pkg/types"
 	utilnet "k8s.io/apimachinery/pkg/util/net"
 	"k8s.io/apimachinery/pkg/util/strategicpatch"
 
+	"sigs.k8s.io/kwok/pkg/utils/lifecycle"
 	utilsnet "sigs.k8s.io/kwok/pkg/utils/net"
 	"sigs.k8s.io/kwok/pkg/utils/wait"
 )
@@ -119,7 +122,7 @@ func labelsParse(selector string) (labels.Selector, error) {
 
 type resourceStageJob[T any] struct {
 	Resource T
-	Stage    *LifecycleStage
+	Stage    *lifecycle.Stage
 	Key      string
 	// RetryCount is used for tracking the retry times of a job.
 	// Must be initialized to 0.
@@ -156,8 +159,32 @@ func shouldRetry(err error) bool {
 	return false
 }
 
-// checkNeedStrategicMergePatch checks if the object needs to be patched
-func checkNeedStrategicMergePatch[T any](obj T, patchData []byte) (bool, error) {
+func checkNeedPatchWithTyped[T any](obj T, patchData []byte, patchType types.PatchType) (bool, error) {
+	switch patchType {
+	case types.JSONPatchType:
+		return checkNeedJSONPatch(obj, patchData)
+	case types.StrategicMergePatchType:
+		return checkNeedStrategicMergePatchWithTyped(obj, patchData)
+	case types.MergePatchType:
+		return checkNeedMergePatchWithTyped(obj, patchData)
+	}
+	return false, fmt.Errorf("unknown patch type %s", patchType)
+}
+
+func checkNeedPatch(obj any, patchData []byte, patchType types.PatchType, schema strategicpatch.LookupPatchMeta) (bool, error) {
+	switch patchType {
+	case types.JSONPatchType:
+		return checkNeedJSONPatch(obj, patchData)
+	case types.StrategicMergePatchType:
+		return checkNeedStrategicMergePatchWithMeta(obj, patchData, schema)
+	case types.MergePatchType:
+		return checkNeedMergePatch(obj, patchData)
+	}
+	return false, fmt.Errorf("unknown patch type %s", patchType)
+}
+
+// checkNeedStrategicMergePatchWithTyped checks if the object needs to be patched
+func checkNeedStrategicMergePatchWithTyped[T any](obj T, patchData []byte) (bool, error) {
 	original, err := json.Marshal(obj)
 	if err != nil {
 		return false, err
@@ -186,8 +213,8 @@ func checkNeedStrategicMergePatch[T any](obj T, patchData []byte) (bool, error)
 	return true, nil
 }
 
-// checkNeedMergePatch checks if the object needs to be patched
-func checkNeedMergePatch[T any](obj T, patchData []byte) (bool, error) {
+// checkNeedMergePatchWithTyped checks if the object needs to be patched
+func checkNeedMergePatchWithTyped[T any](obj T, patchData []byte) (bool, error) {
 	original, err := json.Marshal(obj)
 	if err != nil {
 		return false, err
@@ -217,7 +244,7 @@ func checkNeedMergePatch[T any](obj T, patchData []byte) (bool, error) {
 }
 
 // checkNeedJSONPatch checks if the object needs to be patched
-func checkNeedJSONPatch[T any](obj T, patchData []byte) (bool, error) {
+func checkNeedJSONPatch(obj any, patchData []byte) (bool, error) {
 	original, err := json.Marshal(obj)
 	if err != nil {
 		return false, err
@@ -240,38 +267,38 @@ func checkNeedJSONPatch[T any](obj T, patchData []byte) (bool, error) {
 	return true, nil
 }
 
-// wrapMergePatchData wraps the patch data with the parent key
-func wrapMergePatchData(parent string, patchData []byte) ([]byte, error) {
-	if parent == "" {
-		return patchData, nil
+func checkNeedStrategicMergePatchWithMeta(obj any, patchData []byte, schema strategicpatch.LookupPatchMeta) (bool, error) {
+	original, err := json.Marshal(obj)
+	if err != nil {
+		return false, err
 	}
-	return json.Marshal(map[string]json.RawMessage{
-		parent: patchData,
-	})
-}
 
-// wrapJSONPatchData wraps the patch data with the parent key
-func wrapJSONPatchData(parent string, patchData []byte) ([]byte, error) {
-	if parent == "" {
-		return patchData, nil
+	sum, err := strategicpatch.StrategicMergePatchUsingLookupPatchMeta(original, patchData, schema)
+	if err != nil {
+		return false, err
 	}
 
-	var data []jsonpatchData
-	err := json.Unmarshal(patchData, &data)
+	if bytes.Equal(sum, original) {
+		return false, nil
+	}
+
+	return true, nil
+}
+
+func checkNeedMergePatch(obj any, patchData []byte) (bool, error) {
+	original, err := json.Marshal(obj)
 	if err != nil {
-		return nil, err
+		return false, err
 	}
 
-	for i := range data {
-		data[i].Path = "/" + parent + data[i].Path
+	sum, err := jsonpatch.MergePatch(original, patchData)
+	if err != nil {
+		return false, err
 	}
 
-	return json.Marshal(data)
-}
+	if bytes.Equal(sum, original) {
+		return false, nil
+	}
 
-type jsonpatchData struct {
-	Op    string          `json:"op"`
-	Path  string          `json:"path"`
-	Value json.RawMessage `json:"value,omitempty"`
-	From  string          `json:"from,omitempty"`
+	return true, nil
 }
diff --git a/pkg/utils/lifecycle/doc.go b/pkg/utils/lifecycle/doc.go
new file mode 100644
index 00000000..0311ef7e
--- /dev/null
+++ b/pkg/utils/lifecycle/doc.go
@@ -0,0 +1,18 @@
+/*
+Copyright 2024 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+// Package lifecycle provides utilities to manipulate lifecycle of resources.
+package lifecycle
diff --git a/pkg/kwok/controllers/finalizers.go b/pkg/utils/lifecycle/finalizers.go
similarity index 99%
rename from pkg/kwok/controllers/finalizers.go
rename to pkg/utils/lifecycle/finalizers.go
index ee47f3ae..6eb01a10 100644
--- a/pkg/kwok/controllers/finalizers.go
+++ b/pkg/utils/lifecycle/finalizers.go
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-package controllers
+package lifecycle
 
 import (
 	"strconv"
diff --git a/pkg/kwok/controllers/lifecycle.go b/pkg/utils/lifecycle/lifecycle.go
similarity index 87%
rename from pkg/kwok/controllers/lifecycle.go
rename to pkg/utils/lifecycle/lifecycle.go
index 937b0b73..5eacc8be 100644
--- a/pkg/kwok/controllers/lifecycle.go
+++ b/pkg/utils/lifecycle/lifecycle.go
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-package controllers
+package lifecycle
 
 import (
 	"context"
@@ -33,7 +33,7 @@ import (
 func NewLifecycle(stages []*internalversion.Stage) (Lifecycle, error) {
 	lcs := Lifecycle{}
 	for _, stage := range stages {
-		lc, err := NewLifecycleStage(stage)
+		lc, err := NewStage(stage)
 		if err != nil {
 			return nil, fmt.Errorf("lifecycle stage: %w", err)
 		}
@@ -46,10 +46,10 @@ func NewLifecycle(stages []*internalversion.Stage) (Lifecycle, error) {
 }
 
 // Lifecycle is a list of lifecycle stage.
-type Lifecycle []*LifecycleStage
+type Lifecycle []*Stage
 
-func (s Lifecycle) match(label, annotation labels.Set, data interface{}) ([]*LifecycleStage, error) {
-	out := []*LifecycleStage{}
+func (s Lifecycle) match(label, annotation labels.Set, data interface{}) ([]*Stage, error) {
+	out := []*Stage{}
 	for _, stage := range s {
 		ok, err := stage.match(label, annotation, data)
 		if err != nil {
@@ -63,7 +63,7 @@ func (s Lifecycle) match(label, annotation labels.Set, data interface{}) ([]*Lif
 }
 
 // Match returns matched stage.
-func (s Lifecycle) Match(label, annotation labels.Set, data interface{}) (*LifecycleStage, error) {
+func (s Lifecycle) Match(label, annotation labels.Set, data interface{}) (*Stage, error) {
 	data, err := expression.ToJSONStandard(data)
 	if err != nil {
 		return nil, err
@@ -101,9 +101,9 @@ func (s Lifecycle) Match(label, annotation labels.Set, data interface{}) (*Lifec
 	return stages[len(stages)-1], nil
 }
 
-// NewLifecycleStage returns a new LifecycleStage.
-func NewLifecycleStage(s *internalversion.Stage) (*LifecycleStage, error) {
-	stage := &LifecycleStage{
+// NewStage returns a new Stage.
+func NewStage(s *internalversion.Stage) (*Stage, error) {
+	stage := &Stage{
 		name: s.Name,
 	}
 	selector := s.Spec.Selector
@@ -171,8 +171,8 @@ func NewLifecycleStage(s *internalversion.Stage) (*LifecycleStage, error) {
 	return stage, nil
 }
 
-// LifecycleStage is a resource lifecycle stage manager
-type LifecycleStage struct {
+// Stage is a resource lifecycle stage manager
+type Stage struct {
 	name             string
 	matchLabels      labels.Selector
 	matchAnnotations labels.Selector
@@ -187,7 +187,7 @@ type LifecycleStage struct {
 	immediateNextStage bool
 }
 
-func (s *LifecycleStage) match(label, annotation labels.Set, jsonStandard interface{}) (bool, error) {
+func (s *Stage) match(label, annotation labels.Set, jsonStandard interface{}) (bool, error) {
 	if s.matchLabels != nil {
 		if !s.matchLabels.Matches(label) {
 			return false, nil
@@ -215,7 +215,7 @@ func (s *LifecycleStage) match(label, annotation labels.Set, jsonStandard interf
 
 // Delay returns the delay duration of the stage.
 // It's not a constant value, it can be a random value.
-func (s *LifecycleStage) Delay(ctx context.Context, v interface{}, now time.Time) (time.Duration, bool) {
+func (s *Stage) Delay(ctx context.Context, v interface{}, now time.Time) (time.Duration, bool) {
 	if s.duration == nil {
 		return 0, false
 	}
@@ -243,16 +243,16 @@ func (s *LifecycleStage) Delay(ctx context.Context, v interface{}, now time.Time
 }
 
 // Next returns the next of the stage.
-func (s *LifecycleStage) Next() *internalversion.StageNext {
-	return s.next
+func (s *Stage) Next() *Next {
+	return newNext(s.next)
 }
 
 // Name returns the name of the stage
-func (s *LifecycleStage) Name() string {
+func (s *Stage) Name() string {
 	return s.name
 }
 
 // ImmediateNextStage returns whether the stage is immediate next stage.
-func (s *LifecycleStage) ImmediateNextStage() bool {
+func (s *Stage) ImmediateNextStage() bool {
 	return s.immediateNextStage
 }
diff --git a/pkg/utils/lifecycle/next.go b/pkg/utils/lifecycle/next.go
new file mode 100644
index 00000000..b5d50204
--- /dev/null
+++ b/pkg/utils/lifecycle/next.go
@@ -0,0 +1,180 @@
+/*
+Copyright 2024 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package lifecycle
+
+import (
+	"encoding/json"
+	"fmt"
+
+	"k8s.io/apimachinery/pkg/types"
+
+	"sigs.k8s.io/kwok/pkg/apis/internalversion"
+	"sigs.k8s.io/kwok/pkg/utils/format"
+	"sigs.k8s.io/kwok/pkg/utils/gotpl"
+)
+
+// Next represents the next step in the lifecycle
+type Next struct {
+	next *internalversion.StageNext
+}
+
+// newNext creates a new Next from the stage
+func newNext(next *internalversion.StageNext) *Next {
+	return &Next{
+		next: next,
+	}
+}
+
+// Finalizers returns the finalizers patch
+func (n *Next) Finalizers(metaFinalizers []string) (*Patch, error) {
+	if n.next.Finalizers == nil {
+		return nil, nil
+	}
+	ops := finalizersModify(metaFinalizers, n.next.Finalizers)
+	if len(ops) == 0 {
+		return nil, nil
+	}
+	data, err := json.Marshal(ops)
+	if err != nil {
+		return nil, err
+	}
+
+	return &Patch{
+		Data: data,
+		Type: types.JSONPatchType,
+	}, nil
+}
+
+// Event returns the event for the resource
+func (n *Next) Event() *internalversion.StageEvent {
+	return n.next.Event
+}
+
+// Delete returns whether the resource should be deleted
+func (n *Next) Delete() bool {
+	return n.next.Delete
+}
+
+// Patches returns the patches for the resource
+func (n *Next) Patches(resource any, renderer gotpl.Renderer) ([]*Patch, error) {
+	patches := make([]*Patch, 0, len(n.next.Patches))
+	for _, patch := range n.next.Patches {
+		patchData, patchType, err := computePatch(renderer, resource, patch)
+		if err != nil {
+			return nil, err
+		}
+		patches = append(patches, &Patch{
+			Data:          patchData,
+			Type:          patchType,
+			Subresource:   patch.Subresource,
+			Impersonation: patch.Impersonation,
+		})
+	}
+	return patches, nil
+}
+
+// Patch represents a patch for the resource
+type Patch struct {
+	Data          []byte
+	Type          types.PatchType
+	Subresource   string
+	Impersonation *internalversion.ImpersonationConfig
+}
+
+func computePatch(renderer gotpl.Renderer, resource any, patch internalversion.StagePatch) ([]byte, types.PatchType, error) {
+	switch format.ElemOrDefault(patch.Type) {
+	case internalversion.StagePatchTypeJSONPatch:
+		patchData, err := computeJSONPatch(renderer, resource, patch.Root, patch.Template)
+		if err != nil {
+			return nil, "", err
+		}
+		return patchData, types.JSONPatchType, nil
+	case internalversion.StagePatchTypeStrategicMergePatch:
+		patchData, err := computeMergePatch(renderer, resource, patch.Root, patch.Template)
+		if err != nil {
+			return nil, "", err
+		}
+		return patchData, types.StrategicMergePatchType, nil
+	case internalversion.StagePatchTypeMergePatch, "":
+		patchData, err := computeMergePatch(renderer, resource, patch.Root, patch.Template)
+		if err != nil {
+			return nil, "", err
+		}
+		return patchData, types.MergePatchType, nil
+	}
+
+	return nil, "", fmt.Errorf("unknown patch type %s", *patch.Type)
+}
+
+func computeMergePatch(renderer gotpl.Renderer, resource any, root, tpl string) ([]byte, error) {
+	patchData, err := renderer.ToJSON(tpl, resource)
+	if err != nil {
+		return nil, err
+	}
+
+	return wrapMergePatchData(root, patchData)
+}
+
+func computeJSONPatch(renderer gotpl.Renderer, resource any, root, tpl string) ([]byte, error) {
+	patchData, err := renderer.ToJSON(tpl, resource)
+	if err != nil {
+		return nil, err
+	}
+
+	patchData, err = wrapJSONPatchData(root, patchData)
+	if err != nil {
+		return nil, err
+	}
+
+	return patchData, nil
+}
+
+// wrapMergePatchData wraps the patch data with the parent key
+func wrapMergePatchData(parent string, patchData []byte) ([]byte, error) {
+	if parent == "" {
+		return patchData, nil
+	}
+	return json.Marshal(map[string]json.RawMessage{
+		parent: patchData,
+	})
+}
+
+// wrapJSONPatchData wraps the patch data with the parent key
+func wrapJSONPatchData(parent string, patchData []byte) ([]byte, error) {
+	if parent == "" {
+		return patchData, nil
+	}
+
+	var data []jsonpatchData
+	err := json.Unmarshal(patchData, &data)
+	if err != nil {
+		return nil, err
+	}
+
+	for i := range data {
+		data[i].Path = "/" + parent + data[i].Path
+	}
+
+	return json.Marshal(data)
+}
+
+type jsonpatchData struct {
+	Op    string          `json:"op"`
+	Path  string          `json:"path"`
+	Value json.RawMessage `json:"value,omitempty"`
+	From  string          `json:"from,omitempty"`
+}


Test patch:
diff --git a/pkg/kwok/controllers/node_controller_test.go b/pkg/kwok/controllers/node_controller_test.go
index a35e298b..bbf030b6 100644
--- a/pkg/kwok/controllers/node_controller_test.go
+++ b/pkg/kwok/controllers/node_controller_test.go
@@ -34,6 +34,7 @@ import (
 	"sigs.k8s.io/kwok/pkg/config/resources"
 	"sigs.k8s.io/kwok/pkg/log"
 	"sigs.k8s.io/kwok/pkg/utils/informer"
+	"sigs.k8s.io/kwok/pkg/utils/lifecycle"
 	"sigs.k8s.io/kwok/pkg/utils/wait"
 )
 
@@ -78,11 +79,11 @@ func TestNodeController(t *testing.T) {
 	nodeInit, _ := config.UnmarshalWithType[*internalversion.Stage](nodefast.DefaultNodeInit)
 	nodeStages := []*internalversion.Stage{nodeInit}
 
-	lifecycle, _ := NewLifecycle(nodeStages)
+	lc, _ := lifecycle.NewLifecycle(nodeStages)
 	nodes, err := NewNodeController(NodeControllerConfig{
 		TypedClient:          clientset,
 		NodeIP:               "10.0.0.1",
-		Lifecycle:            resources.NewStaticGetter(lifecycle),
+		Lifecycle:            resources.NewStaticGetter(lc),
 		FuncMap:              defaultFuncMap,
 		PlayStageParallelism: 2,
 	})
diff --git a/pkg/kwok/controllers/pod_controller_test.go b/pkg/kwok/controllers/pod_controller_test.go
index 036003ea..8e60111a 100644
--- a/pkg/kwok/controllers/pod_controller_test.go
+++ b/pkg/kwok/controllers/pod_controller_test.go
@@ -36,6 +36,7 @@ import (
 	"sigs.k8s.io/kwok/pkg/config/resources"
 	"sigs.k8s.io/kwok/pkg/log"
 	"sigs.k8s.io/kwok/pkg/utils/informer"
+	"sigs.k8s.io/kwok/pkg/utils/lifecycle"
 	"sigs.k8s.io/kwok/pkg/utils/slices"
 	"sigs.k8s.io/kwok/pkg/utils/wait"
 )
@@ -190,7 +191,7 @@ func TestPodController(t *testing.T) {
 		t.Fatal(fmt.Errorf("failed to watch nodes: %w", err))
 	}
 
-	lifecycle, _ := NewLifecycle(podStages)
+	lc, _ := lifecycle.NewLifecycle(podStages)
 	annotationSelector, _ := labels.Parse("fake=custom")
 	pods, err := NewPodController(PodControllerConfig{
 		TypedClient:                           clientset,
@@ -198,7 +199,7 @@ func TestPodController(t *testing.T) {
 		NodeIP:                                defaultNodeIP,
 		CIDR:                                  defaultPodCIDR,
 		DisregardStatusWithAnnotationSelector: annotationSelector.String(),
-		Lifecycle:                             resources.NewStaticGetter(lifecycle),
+		Lifecycle:                             resources.NewStaticGetter(lc),
 		NodeGetFunc:                           nodeGetFunc,
 		FuncMap:                               defaultFuncMap,
 		PlayStageParallelism:                  2,
diff --git a/pkg/kwok/controllers/stage_controller_test.go b/pkg/kwok/controllers/stage_controller_test.go
index 09994b0a..0c38d791 100644
--- a/pkg/kwok/controllers/stage_controller_test.go
+++ b/pkg/kwok/controllers/stage_controller_test.go
@@ -34,6 +34,7 @@ import (
 	"sigs.k8s.io/kwok/pkg/config/resources"
 	"sigs.k8s.io/kwok/pkg/log"
 	"sigs.k8s.io/kwok/pkg/utils/informer"
+	"sigs.k8s.io/kwok/pkg/utils/lifecycle"
 	"sigs.k8s.io/kwok/pkg/utils/yaml"
 )
 
@@ -53,7 +54,7 @@ func TestStageController(t *testing.T) {
 
 	gvr := corev1.SchemeGroupVersion.WithResource("persistentvolumes")
 
-	lifecycle, _ := NewLifecycle([]*internalversion.Stage{
+	lc, _ := lifecycle.NewLifecycle([]*internalversion.Stage{
 		{
 			ObjectMeta: metav1.ObjectMeta{
 				Name: "pv-available",
@@ -86,7 +87,7 @@ func TestStageController(t *testing.T) {
 		GVR:                  gvr,
 		DynamicClient:        client,
 		Schema:               patchMeta,
-		Lifecycle:            resources.NewStaticGetter(lifecycle),
+		Lifecycle:            resources.NewStaticGetter(lc),
 		FuncMap:              defaultFuncMap,
 	})
 	if err != nil {
diff --git a/pkg/kwok/controllers/finalizers_test.go b/pkg/utils/lifecycle/finalizers_test.go
similarity index 99%
rename from pkg/kwok/controllers/finalizers_test.go
rename to pkg/utils/lifecycle/finalizers_test.go
index ad03c023..65ff0b81 100644
--- a/pkg/kwok/controllers/finalizers_test.go
+++ b/pkg/utils/lifecycle/finalizers_test.go
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-package controllers
+package lifecycle
 
 import (
 	"reflect"