Repository: open-cluster-management-io/ocm
Pull request #932: ✨ Support configuring resource requirements for addon agents
Author: login: zhujian7
Labels: name: lgtm
name: approved
Created: 2025-04-01T13:41:46Z | Merged: 2025-04-08T02:26:49Z

<!--
Thanks for creating a pull request!

If this is your first time, please make sure to review CONTRIBUTING.MD.

Please copy the appropriate `:text:` or icon to the beginning of your PR title:

:sparkles: ✨ feature
:bug: 🐛 bug fix
:book: 📖 docs
:memo: 📝 proposal
:warning: ⚠️ breaking change
:seedling: 🌱 other/misc
:question: ❓ requires manual review/categorization

-->
## Summary

## Related issue(s)

Fixes #


Diff:
diff --git a/go.mod b/go.mod
index d5b9cb1015..8e6405d4bc 100644
--- a/go.mod
+++ b/go.mod
@@ -35,7 +35,7 @@ require (
 	k8s.io/klog/v2 v2.130.1
 	k8s.io/kube-aggregator v0.31.4
 	k8s.io/utils v0.0.0-20240921022957-49e7df575cb6
-	open-cluster-management.io/addon-framework v0.12.0
+	open-cluster-management.io/addon-framework v0.12.1-0.20250401143304-75b65b5f45e0
 	open-cluster-management.io/api v0.16.1
 	open-cluster-management.io/sdk-go v0.16.1-0.20250327091909-6bd6228a47ad
 	sigs.k8s.io/cluster-inventory-api v0.0.0-20240730014211-ef0154379848
diff --git a/go.sum b/go.sum
index 050b9247db..b9b7bb5446 100644
--- a/go.sum
+++ b/go.sum
@@ -487,8 +487,8 @@ k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 h1:BZqlfIlq5YbRMFko6/PM7F
 k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340/go.mod h1:yD4MZYeKMBwQKVht279WycxKyM84kkAx2DPrTXaeb98=
 k8s.io/utils v0.0.0-20240921022957-49e7df575cb6 h1:MDF6h2H/h4tbzmtIKTuctcwZmY0tY9mD9fNT47QO6HI=
 k8s.io/utils v0.0.0-20240921022957-49e7df575cb6/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
-open-cluster-management.io/addon-framework v0.12.0 h1:5j7mpyk2ij0SLUZkwWk0KkNTWtsid2w7BIHmhm0Ecok=
-open-cluster-management.io/addon-framework v0.12.0/go.mod h1:eReMWXrEHqtilwz5wzEpUrWw9Vfz0HJCH9pi3gOTZns=
+open-cluster-management.io/addon-framework v0.12.1-0.20250401143304-75b65b5f45e0 h1:1gK9/3EkjYlXwrGLlF8i/wOAJfU6gzg4b9Zje+umIJY=
+open-cluster-management.io/addon-framework v0.12.1-0.20250401143304-75b65b5f45e0/go.mod h1:eReMWXrEHqtilwz5wzEpUrWw9Vfz0HJCH9pi3gOTZns=
 open-cluster-management.io/api v0.16.1 h1:mS+4UGxHLPQd7CRM0gdFQdVaz139Lo2bkLfqSE0CDNU=
 open-cluster-management.io/api v0.16.1/go.mod h1:9erZEWEn4bEqh0nIX2wA7f/s3KCuFycQdBrPrRzi0QM=
 open-cluster-management.io/sdk-go v0.16.1-0.20250327091909-6bd6228a47ad h1:37f9TEwX/U8esBjSJvPleDM3rcFpk9NY5e2ItjO6PcQ=
diff --git a/pkg/addon/controllers/addontemplate/controller.go b/pkg/addon/controllers/addontemplate/controller.go
index 20d7c7ac83..387c05ca25 100644
--- a/pkg/addon/controllers/addontemplate/controller.go
+++ b/pkg/addon/controllers/addontemplate/controller.go
@@ -208,6 +208,7 @@ func (c *addonTemplateController) runController(ctx context.Context, addonName s
 			templateagent.ToAddOnRegistriesPrivateValues,
 			templateagent.ToAddOnInstallNamespacePrivateValues,
 			templateagent.ToAddOnProxyPrivateValues,
+			templateagent.ToAddOnResourceRequirementsPrivateValues,
 		),
 	)
 	err = mgr.AddAgent(agentAddon)
diff --git a/pkg/addon/controllers/addontemplate/controller_test.go b/pkg/addon/controllers/addontemplate/controller_test.go
index aa796c1a45..f227d8a0cd 100644
--- a/pkg/addon/controllers/addontemplate/controller_test.go
+++ b/pkg/addon/controllers/addontemplate/controller_test.go
@@ -233,7 +233,7 @@ func TestRunController(t *testing.T) {
 		{
 			name:        "fake kubeconfig",
 			addonName:   "test",
-			expectedErr: `Get "http://localhost/api": dial tcp [::1]:80: connect: connection refused`,
+			expectedErr: `connect: connection refused`,
 		},
 	}
 
@@ -261,9 +261,10 @@ func TestRunController(t *testing.T) {
 		ctx := context.TODO()
 
 		err := controller.runController(ctx, c.addonName)
-		if len(c.expectedErr) == 0 {
-			assert.NoError(t, err)
+		if err == nil {
+			assert.Empty(t, c.expectedErr)
+		} else {
+			assert.Contains(t, err.Error(), c.expectedErr, "name : %s, expected error %v, but got %v", c.name, c.expectedErr, err)
 		}
-		assert.EqualErrorf(t, err, c.expectedErr, "name : %s, expected error %v, but got %v", c.name, c.expectedErr, err)
 	}
 }
diff --git a/pkg/addon/templateagent/decorator.go b/pkg/addon/templateagent/decorator.go
index 263b6ff3ca..6a6f71b367 100644
--- a/pkg/addon/templateagent/decorator.go
+++ b/pkg/addon/templateagent/decorator.go
@@ -3,6 +3,7 @@ package templateagent
 import (
 	"fmt"
 	"path"
+	"regexp"
 	"strings"
 
 	corev1 "k8s.io/api/core/v1"
@@ -106,6 +107,7 @@ func newDeploymentDecorator(
 			newNodePlacementDecorator(privateValues),
 			newImageDecorator(privateValues),
 			newProxyHandler(logger, addonName, privateValues),
+			newResourceRequirementsDecorator(logger, supportResourceDeployment, privateValues),
 		},
 	}
 }
@@ -118,7 +120,7 @@ func (d *deploymentDecorator) decorate(obj *unstructured.Unstructured) (*unstruc
 	}
 
 	for _, decorator := range d.decorators {
-		err = decorator.decorate(&deployment.Spec.Template)
+		err = decorator.decorate(deployment.Name, &deployment.Spec.Template)
 		if err != nil {
 			return obj, err
 		}
@@ -152,6 +154,7 @@ func newDaemonSetDecorator(
 			newNodePlacementDecorator(privateValues),
 			newImageDecorator(privateValues),
 			newProxyHandler(logger, addonName, privateValues),
+			newResourceRequirementsDecorator(logger, supportResourceDaemonset, privateValues),
 		},
 	}
 }
@@ -164,7 +167,7 @@ func (d *daemonSetDecorator) decorate(obj *unstructured.Unstructured) (*unstruct
 	}
 
 	for _, decorator := range d.decorators {
-		err = decorator.decorate(&daemonSet.Spec.Template)
+		err = decorator.decorate(daemonSet.Name, &daemonSet.Spec.Template)
 		if err != nil {
 			return obj, err
 		}
@@ -180,7 +183,8 @@ func (d *daemonSetDecorator) decorate(obj *unstructured.Unstructured) (*unstruct
 
 type podTemplateSpecDecorator interface {
 	// decorate modifies the pod template in place
-	decorate(pod *corev1.PodTemplateSpec) error
+	//   resourceName is the name of the resource, could be a deployment name or a daemonset name
+	decorate(resourceName string, pod *corev1.PodTemplateSpec) error
 }
 
 type environmentDecorator struct {
@@ -192,7 +196,7 @@ func newEnvironmentDecorator(orderedValues orderedValues) podTemplateSpecDecorat
 		orderedValues: orderedValues,
 	}
 }
-func (d *environmentDecorator) decorate(pod *corev1.PodTemplateSpec) error {
+func (d *environmentDecorator) decorate(_ string, pod *corev1.PodTemplateSpec) error {
 	envVars := make([]corev1.EnvVar, len(d.orderedValues))
 	for index, value := range d.orderedValues {
 		envVars[index] = corev1.EnvVar{
@@ -222,7 +226,7 @@ func newVolumeDecorator(addonName string, template *addonapiv1alpha1.AddOnTempla
 	}
 }
 
-func (d *volumeDecorator) decorate(pod *corev1.PodTemplateSpec) error {
+func (d *volumeDecorator) decorate(_ string, pod *corev1.PodTemplateSpec) error {
 
 	volumeMounts := []corev1.VolumeMount{}
 	volumes := []corev1.Volume{}
@@ -289,7 +293,7 @@ func newNodePlacementDecorator(privateValues addonfactory.Values) podTemplateSpe
 	}
 }
 
-func (d *nodePlacementDecorator) decorate(pod *corev1.PodTemplateSpec) error {
+func (d *nodePlacementDecorator) decorate(_ string, pod *corev1.PodTemplateSpec) error {
 	nodePlacement, ok := d.privateValues[NodePlacementPrivateValueKey]
 	if !ok {
 		return nil
@@ -321,7 +325,7 @@ func newImageDecorator(privateValues addonfactory.Values) podTemplateSpecDecorat
 	}
 }
 
-func (d *imageDecorator) decorate(pod *corev1.PodTemplateSpec) error {
+func (d *imageDecorator) decorate(_ string, pod *corev1.PodTemplateSpec) error {
 	registries, ok := d.privateValues[RegistriesPrivateValueKey]
 	if !ok {
 		return nil
@@ -368,7 +372,7 @@ func newProxyHandler(logger klog.Logger, addonName string, privateValues addonfa
 	}
 }
 
-func (d *proxyHandler) decorate(pod *corev1.PodTemplateSpec) error {
+func (d *proxyHandler) decorate(name string, pod *corev1.PodTemplateSpec) error {
 	pc, ok := d.getProxyConfig()
 	if !ok {
 		return nil
@@ -398,7 +402,7 @@ func (d *proxyHandler) decorate(pod *corev1.PodTemplateSpec) error {
 		return nil
 	}
 
-	err := newEnvironmentDecorator(keyValues).decorate(pod)
+	err := newEnvironmentDecorator(keyValues).decorate(name, pod)
 	if err != nil {
 		return err
 	}
@@ -407,7 +411,7 @@ func (d *proxyHandler) decorate(pod *corev1.PodTemplateSpec) error {
 		return nil
 	}
 
-	return newCABundleDecorator(d.addonName, pc.CABundle).decorate(pod)
+	return newCABundleDecorator(d.addonName, pc.CABundle).decorate(name, pod)
 }
 
 func (d *proxyHandler) getProxyConfig() (addonapiv1alpha1.ProxyConfig, bool) {
@@ -474,8 +478,8 @@ func newCABundleDecorator(addonName string, caBundle []byte) podTemplateSpecDeco
 	}
 }
 
-func (d *caBundleDecorator) decorate(pod *corev1.PodTemplateSpec) error {
-	err := d.envDecorator.decorate(pod)
+func (d *caBundleDecorator) decorate(name string, pod *corev1.PodTemplateSpec) error {
+	err := d.envDecorator.decorate(name, pod)
 	if err != nil {
 		return err
 	}
@@ -508,6 +512,60 @@ func (d *caBundleDecorator) decorate(pod *corev1.PodTemplateSpec) error {
 	return nil
 }
 
+type supportResource string
+
+const (
+	supportResourceDeployment supportResource = "deployments"
+	supportResourceDaemonset  supportResource = "daemonsets"
+)
+
+type resourceRequirementsDecorator struct {
+	privateValues addonfactory.Values
+	resource      supportResource // only support daemonsets, deployments for now
+	logger        klog.Logger
+}
+
+func newResourceRequirementsDecorator(logger klog.Logger, resource supportResource,
+	privateValues addonfactory.Values) podTemplateSpecDecorator {
+	return &resourceRequirementsDecorator{
+		resource:      resource,
+		privateValues: privateValues,
+	}
+}
+
+func (d *resourceRequirementsDecorator) decorate(name string, pod *corev1.PodTemplateSpec) error {
+	requirements, ok := d.privateValues[ResourceRequirementsPrivateValueKey]
+	if !ok {
+		return nil
+	}
+
+	regexRequirements, ok := requirements.([]addonfactory.RegexResourceRequirements)
+	if !ok {
+		return fmt.Errorf("resource requirements value is invalid")
+	}
+
+	for i := range pod.Spec.Containers {
+		containerID := fmt.Sprintf("%s:%s:%s", d.resource, name, pod.Spec.Containers[i].Name)
+		// revese the requirements array to make the later elements in the array have higher priority
+		for j := len(regexRequirements) - 1; j >= 0; j-- {
+			matched, err := regexp.MatchString(regexRequirements[j].ContainerIDRegex, containerID)
+			if err != nil {
+				d.logger.Info("regex match container id failed", "pattern",
+					regexRequirements[j].ContainerIDRegex, "containerID", containerID)
+				continue
+			}
+			if !matched {
+				continue
+			}
+
+			pod.Spec.Containers[i].Resources = regexRequirements[j].ResourcesRaw
+			break
+		}
+	}
+
+	return nil
+}
+
 func hubKubeconfigSecretMountPath() string {
 	return "/managed/hub-kubeconfig"
 }
diff --git a/pkg/addon/templateagent/decorator_test.go b/pkg/addon/templateagent/decorator_test.go
index f851aaabaa..05f59ae3ed 100644
--- a/pkg/addon/templateagent/decorator_test.go
+++ b/pkg/addon/templateagent/decorator_test.go
@@ -6,6 +6,7 @@ import (
 
 	corev1 "k8s.io/api/core/v1"
 	rbacv1 "k8s.io/api/rbac/v1"
+	"k8s.io/apimachinery/pkg/api/resource"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
 	"k8s.io/apimachinery/pkg/runtime"
@@ -249,7 +250,177 @@ func TestProxyDecorator(t *testing.T) {
 			ctx := context.TODO()
 			logger := klog.FromContext(ctx)
 			d := newProxyHandler(logger, "addon1", values)
-			err = d.decorate(tc.pod)
+			err = d.decorate("", tc.pod)
+			if err != nil {
+				t.Fatal(err)
+			}
+			tc.validateObject(t, tc.pod)
+		})
+	}
+
+}
+
+func TestResourceRequirementsDecorator(t *testing.T) {
+	tests := []struct {
+		name            string
+		config          addonapiv1alpha1.AddOnDeploymentConfig
+		resourceName    string
+		pod             *corev1.PodTemplateSpec
+		supportResource supportResource
+		validateObject  func(t *testing.T, pod *corev1.PodTemplateSpec)
+	}{
+		{
+			name: "deployment",
+			pod: &corev1.PodTemplateSpec{
+				Spec: corev1.PodSpec{
+					Containers: []corev1.Container{
+						{
+							Name:  "c1",
+							Image: "test",
+						},
+						{
+							Name:  "c2",
+							Image: "test",
+						},
+					},
+				},
+			},
+			resourceName:    "d1",
+			supportResource: supportResourceDeployment,
+			config: addonapiv1alpha1.AddOnDeploymentConfig{
+				Spec: addonapiv1alpha1.AddOnDeploymentConfigSpec{
+					ResourceRequirements: []addonapiv1alpha1.ContainerResourceRequirements{
+						{
+							ContainerID: "deployments:d1:c1",
+							Resources: corev1.ResourceRequirements{
+								Requests: corev1.ResourceList{
+									corev1.ResourceMemory: resource.MustParse("128Mi"),
+								},
+							},
+						},
+					},
+				},
+			},
+			validateObject: func(t *testing.T, pod *corev1.PodTemplateSpec) {
+				for _, c := range pod.Spec.Containers {
+					if c.Name == "c1" {
+						if c.Resources.Requests.Memory() == nil || c.Resources.Requests.Memory().String() != "128Mi" {
+							t.Errorf("memory request for c1 is not corrent, got %v", c.Resources)
+						}
+					} else {
+						if c.Resources.Requests.Memory() != nil && c.Resources.Requests.Memory().String() != "0" {
+							t.Errorf("memory request for other containers should not be set, got %v", c.Resources)
+						}
+					}
+				}
+			},
+		},
+		{
+			name: "daemonset",
+			pod: &corev1.PodTemplateSpec{
+				Spec: corev1.PodSpec{
+					Containers: []corev1.Container{
+						{
+							Name:  "c1",
+							Image: "test",
+						},
+						{
+							Name:  "c2",
+							Image: "test",
+						},
+					},
+				},
+			},
+			resourceName:    "d1",
+			supportResource: supportResourceDaemonset,
+			config: addonapiv1alpha1.AddOnDeploymentConfig{
+				Spec: addonapiv1alpha1.AddOnDeploymentConfigSpec{
+					ResourceRequirements: []addonapiv1alpha1.ContainerResourceRequirements{
+						{
+							ContainerID: "daemonsets:d1:c1",
+							Resources: corev1.ResourceRequirements{
+								Requests: corev1.ResourceList{
+									corev1.ResourceMemory: resource.MustParse("128Mi"),
+								},
+							},
+						},
+					},
+				},
+			},
+			validateObject: func(t *testing.T, pod *corev1.PodTemplateSpec) {
+				for _, c := range pod.Spec.Containers {
+					if c.Name == "c1" {
+						if c.Resources.Requests.Memory() == nil || c.Resources.Requests.Memory().String() != "128Mi" {
+							t.Errorf("memory request for c1 is not corrent, got %v", c.Resources)
+						}
+					} else {
+						if c.Resources.Requests.Memory() != nil && c.Resources.Requests.Memory().String() != "0" {
+							t.Errorf("memory request for other containers should not be set, got %v", c.Resources)
+						}
+					}
+				}
+			},
+		},
+		{
+			name: "regex match",
+			pod: &corev1.PodTemplateSpec{
+				Spec: corev1.PodSpec{
+					Containers: []corev1.Container{
+						{
+							Name:  "c1",
+							Image: "test",
+						},
+						{
+							Name:  "c2",
+							Image: "test",
+						},
+					},
+				},
+			},
+			resourceName:    "d1",
+			supportResource: supportResourceDeployment,
+			config: addonapiv1alpha1.AddOnDeploymentConfig{
+				Spec: addonapiv1alpha1.AddOnDeploymentConfigSpec{
+					ResourceRequirements: []addonapiv1alpha1.ContainerResourceRequirements{
+						{
+							ContainerID: "deployments:d1:c1",
+							Resources: corev1.ResourceRequirements{
+								Requests: corev1.ResourceList{
+									corev1.ResourceMemory: resource.MustParse("128Mi"),
+								},
+							},
+						},
+						{
+							ContainerID: "*:*:*",
+							Resources: corev1.ResourceRequirements{
+								Requests: corev1.ResourceList{
+									corev1.ResourceMemory: resource.MustParse("256Mi"),
+								},
+							},
+						},
+					},
+				},
+			},
+			validateObject: func(t *testing.T, pod *corev1.PodTemplateSpec) {
+				for _, c := range pod.Spec.Containers {
+					if c.Resources.Requests.Memory() == nil || c.Resources.Requests.Memory().String() != "256Mi" {
+						t.Errorf("memory request for c1 is not corrent, got %v", c.Resources)
+					}
+				}
+			},
+		},
+	}
+
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			values, err := ToAddOnResourceRequirementsPrivateValues(tc.config)
+			if err != nil {
+				t.Fatal(err)
+			}
+			ctx := context.TODO()
+			logger := klog.FromContext(ctx)
+			d := newResourceRequirementsDecorator(logger, tc.supportResource, values)
+			err = d.decorate(tc.resourceName, tc.pod)
 			if err != nil {
 				t.Fatal(err)
 			}
diff --git a/pkg/addon/templateagent/template_agent.go b/pkg/addon/templateagent/template_agent.go
index 07c1d9e385..90585e321a 100644
--- a/pkg/addon/templateagent/template_agent.go
+++ b/pkg/addon/templateagent/template_agent.go
@@ -27,12 +27,21 @@ import (
 const (
 	// Private value keys that are used internally by the addon template controller, should not be exposed to users.
 	// All private value keys should begin with "__"
-	NodePlacementPrivateValueKey    = "__NODE_PLACEMENT"
-	RegistriesPrivateValueKey       = "__REGISTRIES"
-	InstallNamespacePrivateValueKey = "__INSTALL_NAMESPACE"
-	ProxyPrivateValueKey            = "__PROXY"
+	NodePlacementPrivateValueKey        = "__NODE_PLACEMENT"
+	RegistriesPrivateValueKey           = "__REGISTRIES"
+	InstallNamespacePrivateValueKey     = "__INSTALL_NAMESPACE"
+	ProxyPrivateValueKey                = "__PROXY"
+	ResourceRequirementsPrivateValueKey = "__RESOURCE_REQUIREMENTS"
 )
 
+var PrivateValuesKeys = map[string]struct{}{
+	NodePlacementPrivateValueKey:        {},
+	RegistriesPrivateValueKey:           {},
+	InstallNamespacePrivateValueKey:     {},
+	ProxyPrivateValueKey:                {},
+	ResourceRequirementsPrivateValueKey: {},
+}
+
 // templateBuiltinValues includes the built-in values for crd template agentAddon.
 // the values for template config should begin with an uppercase letter, so we need
 // to convert it to Values by JsonStructToValues.
diff --git a/pkg/addon/templateagent/values.go b/pkg/addon/templateagent/values.go
index 12e965cf16..40189df4f2 100644
--- a/pkg/addon/templateagent/values.go
+++ b/pkg/addon/templateagent/values.go
@@ -58,6 +58,19 @@ func ToAddOnProxyPrivateValues(config addonapiv1alpha1.AddOnDeploymentConfig) (a
 	}, nil
 }
 
+func ToAddOnResourceRequirementsPrivateValues(config addonapiv1alpha1.AddOnDeploymentConfig) (addonfactory.Values, error) {
+	if config.Spec.ResourceRequirements == nil {
+		return nil, nil
+	}
+	requirements, err := addonfactory.GetRegexResourceRequirements(config.Spec.ResourceRequirements)
+	if err != nil {
+		return nil, err
+	}
+	return addonfactory.Values{
+		ResourceRequirementsPrivateValueKey: requirements,
+	}, nil
+}
+
 type keyValuePair struct {
 	name  string
 	value string
@@ -84,13 +97,6 @@ func (a *CRDTemplateAgentAddon) getValues(
 	}
 	overrideValues = addonfactory.MergeValues(defaultValues, overrideValues)
 
-	privateValuesKeys := map[string]struct{}{
-		NodePlacementPrivateValueKey:    {},
-		RegistriesPrivateValueKey:       {},
-		InstallNamespacePrivateValueKey: {},
-		ProxyPrivateValueKey:            {},
-	}
-
 	for i := 0; i < len(a.getValuesFuncs); i++ {
 		if a.getValuesFuncs[i] != nil {
 			userValues, err := a.getValuesFuncs[i](cluster, addon)
@@ -100,7 +106,7 @@ func (a *CRDTemplateAgentAddon) getValues(
 
 			publicValues := map[string]interface{}{}
 			for k, v := range userValues {
-				if _, ok := privateValuesKeys[k]; ok {
+				if _, ok := PrivateValuesKeys[k]; ok {
 					privateValues[k] = v
 					continue
 				}
diff --git a/test/e2e/addonmanagement_test.go b/test/e2e/addonmanagement_test.go
index 1fbe8af157..22dac38938 100644
--- a/test/e2e/addonmanagement_test.go
+++ b/test/e2e/addonmanagement_test.go
@@ -13,6 +13,7 @@ import (
 	"k8s.io/apimachinery/pkg/api/equality"
 	"k8s.io/apimachinery/pkg/api/errors"
 	"k8s.io/apimachinery/pkg/api/meta"
+	"k8s.io/apimachinery/pkg/api/resource"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
 	"k8s.io/apimachinery/pkg/runtime"
@@ -33,13 +34,14 @@ import (
 )
 
 const (
-	nodePlacementDeploymentConfigName = "node-placement-deploy-config"
-	imageOverrideDeploymentConfigName = "image-override-deploy-config"
-	namespaceOverrideConfigName       = "namespace-override-config"
-	proxyDeploymentConfigName         = "proxy-deploy-config"
-	originalImageValue                = "quay.io/open-cluster-management/addon-examples:latest"
-	overrideImageValue                = "quay.io/ocm/addon-examples:latest"
-	customSignerName                  = "example.com/signer-name"
+	nodePlacementDeploymentConfigName        = "node-placement-deploy-config"
+	imageOverrideDeploymentConfigName        = "image-override-deploy-config"
+	namespaceOverrideConfigName              = "namespace-override-config"
+	proxyDeploymentConfigName                = "proxy-deploy-config"
+	resourceRequirementsDeploymentConfigName = "resource-requirements-deploy-config"
+	originalImageValue                       = "quay.io/open-cluster-management/addon-examples:latest"
+	overrideImageValue                       = "quay.io/ocm/addon-examples:latest"
+	customSignerName                         = "example.com/signer-name"
 	//#nosec G101
 	customSignerSecretName = "addon-signer-secret"
 )
@@ -59,6 +61,16 @@ var (
 		NoProxy:    "localhost",
 		CABundle:   []byte("test-ca-bundle"),
 	}
+	resourceRequirementsConfig = []addonapiv1alpha1.ContainerResourceRequirements{
+		{
+			ContainerID: "*:*:helloworld-agent",
+			Resources: corev1.ResourceRequirements{
+				Requests: corev1.ResourceList{
+					corev1.ResourceMemory: resource.MustParse("64Mi"),
+				},
+			},
+		},
+	}
 )
 
 var _ = ginkgo.Describe("Enable addon management feature gate", ginkgo.Ordered, ginkgo.Label("addon-manager"), func() {
@@ -744,6 +756,71 @@ var _ = ginkgo.Describe("Enable addon management feature gate", ginkgo.Ordered,
 		gomega.Expect(cm.Data["ca-bundle.crt"]).To(gomega.Equal(string(proxyConfig.CABundle)))
 	})
 
+	ginkgo.It("Template type addon should be configured by addon deployment config for resource requirement", func() {
+		ginkgo.By("Prepare a AddOnDeploymentConfig for addon resource requirement config")
+		gomega.Eventually(func() error {
+			return prepareResourceRequirementsAddOnDeploymentConfig(universalClusterName, addonInstallNamespace)
+		}).ShouldNot(gomega.HaveOccurred())
+
+		ginkgo.By("Add the configs to ManagedClusterAddOn")
+		gomega.Eventually(func() error {
+			addon, err := hub.AddonClient.AddonV1alpha1().ManagedClusterAddOns(universalClusterName).Get(
+				context.Background(), addOnName, metav1.GetOptions{})
+			if err != nil {
+				return err
+			}
+			newAddon := addon.DeepCopy()
+			newAddon.Spec.Configs = []addonapiv1alpha1.AddOnConfig{
+				{
+					ConfigGroupResource: addonapiv1alpha1.ConfigGroupResource{
+						Group:    "addon.open-cluster-management.io",
+						Resource: "addondeploymentconfigs",
+					},
+					ConfigReferent: addonapiv1alpha1.ConfigReferent{
+						Namespace: universalClusterName,
+						Name:      resourceRequirementsDeploymentConfigName,
+					},
+				},
+			}
+			_, err = hub.AddonClient.AddonV1alpha1().ManagedClusterAddOns(universalClusterName).Update(
+				context.Background(), newAddon, metav1.UpdateOptions{})
+			if err != nil {
+				return err
+			}
+			return nil
+		}).ShouldNot(gomega.HaveOccurred())
+
+		ginkgo.By("Make sure addon is configured")
+		gomega.Eventually(func() error {
+			agentDeploy, err := spoke.KubeClient.AppsV1().Deployments(addonInstallNamespace).Get(
+				context.Background(), "hello-template-agent", metav1.GetOptions{})
+			if err != nil {
+				return err
+			}
+
+			for _, container := range agentDeploy.Spec.Template.Spec.Containers {
+				if container.Name == "helloworld-agent" {
+					if !equality.Semantic.DeepEqual(container.Resources, resourceRequirementsConfig[0].Resources) {
+						return fmt.Errorf("unexpected resource requirements for deployment: %v", container.Resources)
+					}
+				}
+			}
+
+			agentDaemonset, err := spoke.KubeClient.AppsV1().DaemonSets(addonInstallNamespace).Get(
+				context.Background(), "hello-template-agent-ds", metav1.GetOptions{})
+			if err != nil {
+				return err
+			}
+			for _, container := range agentDaemonset.Spec.Template.Spec.Containers {
+				if container.Name == "helloworld-agent" {
+					if !equality.Semantic.DeepEqual(container.Resources, resourceRequirementsConfig[0].Resources) {
+						return fmt.Errorf("unexpected resource requirements for daemonset: %v", container.Resources)
+					}
+				}
+			}
+			return nil
+		}).ShouldNot(gomega.HaveOccurred())
+	})
 })
 
 func prepareInstallNamespace(namespace, installNamespace string) error {
@@ -860,6 +937,34 @@ func prepareProxyAddOnDeploymentConfig(namespace, installNamespace string) error
 	return err
 }
 
+func prepareResourceRequirementsAddOnDeploymentConfig(namespace, installNamespace string) error {
+	_, err := hub.AddonClient.AddonV1alpha1().AddOnDeploymentConfigs(namespace).Get(
+		context.Background(), resourceRequirementsDeploymentConfigName, metav1.GetOptions{})
+	if errors.IsNotFound(err) {
+		if _, err := hub.AddonClient.AddonV1alpha1().AddOnDeploymentConfigs(namespace).Create(
+			context.Background(),
+			&addonapiv1alpha1.AddOnDeploymentConfig{
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      resourceRequirementsDeploymentConfigName,
+					Namespace: namespace,
+				},
+				Spec: addonapiv1alpha1.AddOnDeploymentConfigSpec{
+					AgentInstallNamespace: installNamespace,
+					ProxyConfig:           proxyConfig,
+					ResourceRequirements:  resourceRequirementsConfig,
+				},
+			},
+			metav1.CreateOptions{},
+		); err != nil {
+			return err
+		}
+
+		return nil
+	}
+
+	return err
+}
+
 func copySignerSecret(ctx context.Context, kubeClient kubernetes.Interface, srcNs, srcName, dstNs, dstName string) error {
 	src, err := kubeClient.CoreV1().Secrets(srcNs).Get(context.Background(), srcName, metav1.GetOptions{})
 	if err != nil {
diff --git a/vendor/modules.txt b/vendor/modules.txt
index 005356c582..4f11acb3a8 100644
--- a/vendor/modules.txt
+++ b/vendor/modules.txt
@@ -1692,7 +1692,7 @@ k8s.io/utils/pointer
 k8s.io/utils/ptr
 k8s.io/utils/strings/slices
 k8s.io/utils/trace
-# open-cluster-management.io/addon-framework v0.12.0
+# open-cluster-management.io/addon-framework v0.12.1-0.20250401143304-75b65b5f45e0
 ## explicit; go 1.22.0
 open-cluster-management.io/addon-framework/pkg/addonfactory
 open-cluster-management.io/addon-framework/pkg/addonmanager
diff --git a/vendor/open-cluster-management.io/addon-framework/pkg/addonfactory/addondeploymentconfig.go b/vendor/open-cluster-management.io/addon-framework/pkg/addonfactory/addondeploymentconfig.go
index 96bf2721a5..5084727a32 100644
--- a/vendor/open-cluster-management.io/addon-framework/pkg/addonfactory/addondeploymentconfig.go
+++ b/vendor/open-cluster-management.io/addon-framework/pkg/addonfactory/addondeploymentconfig.go
@@ -27,13 +27,16 @@ type resourceRequirements struct {
 	Requests map[string]string `json:"requests,omitempty"`
 }
 
-// regexResourceRequirements defines a resource requirement rule for containers. A container is eligible for the
+// RegexResourceRequirements defines a resource requirement rule for containers. A container is eligible for the
 // specified resource requirements if its container ID matches the regular expression.
-type regexResourceRequirements struct {
+type RegexResourceRequirements struct {
 	// ContainerIDRegex is the regular expression used to match container IDs.
 	ContainerIDRegex string `json:"containerIDRegex"`
-	// Resources defines the resource requirements for matched containers
+	// Resources defines the resource requirements for matched containers, its resource value is plain string
 	Resources resourceRequirements `json:"resources"`
+
+	// ResourcesRaw defines the resource requirements for matched containers, its resource value is structure
+	ResourcesRaw corev1.ResourceRequirements `json:"resourcesRaw"`
 }
 
 // ToAddOnNodePlacementValues only transform the AddOnDeploymentConfig NodePlacement part into Values object that has
@@ -139,13 +142,13 @@ func ToAddOnResourceRequirementsValues(config addonapiv1alpha1.AddOnDeploymentCo
 		return nil, nil
 	}
 
-	resourceRequirements, err := getRegexResourceRequirements(config.Spec.ResourceRequirements)
+	resourceRequirements, err := GetRegexResourceRequirements(config.Spec.ResourceRequirements)
 	if err != nil {
 		return nil, err
 	}
 
 	type global struct {
-		ResourceRequirements []regexResourceRequirements `json:"resourceRequirements"`
+		ResourceRequirements []RegexResourceRequirements `json:"resourceRequirements"`
 	}
 
 	jsonStruct := struct {
@@ -221,8 +224,8 @@ func GetAddOnDeploymentConfigValues(
 	}
 }
 
-func getRegexResourceRequirements(requirements []addonapiv1alpha1.ContainerResourceRequirements) ([]regexResourceRequirements, error) {
-	newRequirements := []regexResourceRequirements{}
+func GetRegexResourceRequirements(requirements []addonapiv1alpha1.ContainerResourceRequirements) ([]RegexResourceRequirements, error) {
+	newRequirements := []RegexResourceRequirements{}
 	for _, item := range requirements {
 		// convert container ID to regex
 		parts := strings.Split(item.ContainerID, ":")
@@ -234,12 +237,13 @@ func getRegexResourceRequirements(requirements []addonapiv1alpha1.ContainerResou
 				parts[index] = ".+"
 			}
 		}
-		newRequirements = append(newRequirements, regexResourceRequirements{
+		newRequirements = append(newRequirements, RegexResourceRequirements{
 			ContainerIDRegex: fmt.Sprintf("^%s:%s:%s$", parts[0], parts[1], parts[2]),
 			Resources: resourceRequirements{
 				Requests: toStringResourceList(item.Resources.Requests),
 				Limits:   toStringResourceList(item.Resources.Limits),
 			},
+			ResourcesRaw: item.Resources,
 		})
 	}
 
@@ -294,7 +298,7 @@ func ToAddOnDeploymentConfigValues(config addonapiv1alpha1.AddOnDeploymentConfig
 	}
 
 	// load ResourceRequirements settings
-	resourceRequirements, err := getRegexResourceRequirements(config.Spec.ResourceRequirements)
+	resourceRequirements, err := GetRegexResourceRequirements(config.Spec.ResourceRequirements)
 	if err != nil {
 		return nil, err
 	}
diff --git a/vendor/open-cluster-management.io/addon-framework/pkg/utils/probe_helper.go b/vendor/open-cluster-management.io/addon-framework/pkg/utils/probe_helper.go
index d4377ebb23..73c7c4a192 100644
--- a/vendor/open-cluster-management.io/addon-framework/pkg/utils/probe_helper.go
+++ b/vendor/open-cluster-management.io/addon-framework/pkg/utils/probe_helper.go
@@ -229,22 +229,7 @@ func ConvertToDeployment(obj runtime.Object) (*appsv1.Deployment, error) {
 		return deployment, nil
 	}
 
-	if obj.GetObjectKind().GroupVersionKind().Group != appsv1.GroupName ||
-		obj.GetObjectKind().GroupVersionKind().Kind != "Deployment" {
-		return nil, fmt.Errorf("not deployment object, %v", obj.GetObjectKind())
-	}
-
-	deployment := &appsv1.Deployment{}
-	uobj, ok := obj.(*unstructured.Unstructured)
-	if !ok {
-		return deployment, fmt.Errorf("not unstructured object, %v", obj.GetObjectKind())
-	}
-
-	err := runtime.DefaultUnstructuredConverter.FromUnstructured(uobj.Object, deployment)
-	if err != nil {
-		return nil, err
-	}
-	return deployment, nil
+	return ConvertTo[appsv1.Deployment](obj, appsv1.GroupName, "Deployment")
 }
 
 func DeploymentWellKnowManifestConfig(namespace, name string) workapiv1.ManifestConfigOption {
@@ -284,24 +269,29 @@ func ConvertToDaemonSet(obj runtime.Object) (*appsv1.DaemonSet, error) {
 		return daemonSet, nil
 	}
 
-	if obj.GetObjectKind().GroupVersionKind().Group != appsv1.GroupName ||
-		obj.GetObjectKind().GroupVersionKind().Kind != "DaemonSet" {
-		return nil, fmt.Errorf("not daemonset object, %v", obj.GetObjectKind())
+	return ConvertTo[appsv1.DaemonSet](obj, appsv1.GroupName, "DaemonSet")
+}
+
+func DaemonSetWellKnowManifestConfig(namespace, name string) workapiv1.ManifestConfigOption {
+	return WellKnowManifestConfig(appsv1.GroupName, "daemonsets", namespace, name)
+}
+func ConvertTo[T any](obj runtime.Object, group, kind string) (*T, error) {
+	// Verify GroupVersionKind matches expected values
+	gvk := obj.GetObjectKind().GroupVersionKind()
+	if gvk.Group != group || gvk.Kind != kind {
+		return nil, fmt.Errorf("not %s object, %v", kind, obj.GetObjectKind())
 	}
 
-	daemonSet := &appsv1.DaemonSet{}
+	// Handle unstructured conversion
 	uobj, ok := obj.(*unstructured.Unstructured)
 	if !ok {
-		return daemonSet, fmt.Errorf("not unstructured object, %v", obj.GetObjectKind())
+		return nil, fmt.Errorf("not unstructured object, %v", obj.GetObjectKind())
 	}
 
-	err := runtime.DefaultUnstructuredConverter.FromUnstructured(uobj.Object, daemonSet)
-	if err != nil {
+	// Create new instance of target type and convert
+	target := new(T)
+	if err := runtime.DefaultUnstructuredConverter.FromUnstructured(uobj.Object, target); err != nil {
 		return nil, err
 	}
-	return daemonSet, nil
-}
-
-func DaemonSetWellKnowManifestConfig(namespace, name string) workapiv1.ManifestConfigOption {
-	return WellKnowManifestConfig(appsv1.GroupName, "daemonsets", namespace, name)
+	return target, nil
 }


Reviews:
user: login: elgnay
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-03T06:08:15Z
user: login: zhujian7
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-03T08:30:41Z

Review comments:
diff_hunk: @@ -508,6 +512,60 @@ func (d *caBundleDecorator) decorate(pod *corev1.PodTemplateSpec) error {
 	return nil
 }
 
+type supportResource string
+
+const (
+	supportResourceDeployment supportResource = "deployments"
+	supportResourceDaemonset  supportResource = "daemonsets"
+)
+
+type resourceRequirementsDecorator struct {
+	privateValues addonfactory.Values
+	resource      supportResource // only support daemonsets, deployments for now
+	logger        klog.Logger
+}
+
+func newResourceRequirementsDecorator(logger klog.Logger, resource supportResource,
+	privateValues addonfactory.Values) podTemplateSpecDecorator {
+	return &resourceRequirementsDecorator{
+		resource:      resource,
+		privateValues: privateValues,
+	}
+}
+
+func (d *resourceRequirementsDecorator) decorate(name string, pod *corev1.PodTemplateSpec) error {
+	requirements, ok := d.privateValues[ResourceRequirementsPrivateValueKey]
+	if !ok {
+		return nil
+	}
+
+	regexRequirements, ok := requirements.([]addonfactory.RegexResourceRequirements)
+	if !ok {
+		return fmt.Errorf("resource requirements value is invalid")
+	}
+
+	for i := range pod.Spec.Containers {
path: pkg/addon/templateagent/decorator.go
user: login: elgnay
body: Should we consider `pod.Spec.InitContainers` as well?
created_at: 2025-04-03T06:08:14Z
author_association: NONE
diff_hunk: @@ -508,6 +512,60 @@ func (d *caBundleDecorator) decorate(pod *corev1.PodTemplateSpec) error {
 	return nil
 }
 
+type supportResource string
+
+const (
+	supportResourceDeployment supportResource = "deployments"
+	supportResourceDaemonset  supportResource = "daemonsets"
+)
+
+type resourceRequirementsDecorator struct {
+	privateValues addonfactory.Values
+	resource      supportResource // only support daemonsets, deployments for now
+	logger        klog.Logger
+}
+
+func newResourceRequirementsDecorator(logger klog.Logger, resource supportResource,
+	privateValues addonfactory.Values) podTemplateSpecDecorator {
+	return &resourceRequirementsDecorator{
+		resource:      resource,
+		privateValues: privateValues,
+	}
+}
+
+func (d *resourceRequirementsDecorator) decorate(name string, pod *corev1.PodTemplateSpec) error {
+	requirements, ok := d.privateValues[ResourceRequirementsPrivateValueKey]
+	if !ok {
+		return nil
+	}
+
+	regexRequirements, ok := requirements.([]addonfactory.RegexResourceRequirements)
+	if !ok {
+		return fmt.Errorf("resource requirements value is invalid")
+	}
+
+	for i := range pod.Spec.Containers {
path: pkg/addon/templateagent/decorator.go
user: login: zhujian7
body: I am not quite sure, all other decorators do not consider the init containers.

created_at: 2025-04-03T08:30:41Z
author_association: MEMBER

PR comments:
user: login: zhujian7
created_at: 2025-04-01T13:45:13Z
body: /hold
author_association: MEMBER
user: login: codecov[bot]
created_at: 2025-04-01T13:48:54Z
body: ## [Codecov](https://app.codecov.io/gh/open-cluster-management-io/ocm/pull/932?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=open-cluster-management-io) Report
Attention: Patch coverage is `75.92593%` with `13 lines` in your changes missing coverage. Please review.
> Project coverage is 64.36%. Comparing base [(`9024cb0`)](https://app.codecov.io/gh/open-cluster-management-io/ocm/commit/9024cb0baf1fbdfbe2e47227870d9d599e6569cc?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=open-cluster-management-io) to head [(`8759211`)](https://app.codecov.io/gh/open-cluster-management-io/ocm/commit/8759211a3d614a08b0eb7ca62a8a6e4959a70a3f?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=open-cluster-management-io).
> Report is 1 commits behind head on main.

| [Files with missing lines](https://app.codecov.io/gh/open-cluster-management-io/ocm/pull/932?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=open-cluster-management-io) | Patch % | Lines |
|---|---|---|
| [pkg/addon/templateagent/decorator.go](https://app.codecov.io/gh/open-cluster-management-io/ocm/pull/932?src=pr&el=tree&filepath=pkg%2Faddon%2Ftemplateagent%2Fdecorator.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=open-cluster-management-io#diff-cGtnL2FkZG9uL3RlbXBsYXRlYWdlbnQvZGVjb3JhdG9yLmdv) | 82.92% | [5 Missing and 2 partials :warning: ](https://app.codecov.io/gh/open-cluster-management-io/ocm/pull/932?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=open-cluster-management-io) |
| [pkg/addon/templateagent/values.go](https://app.codecov.io/gh/open-cluster-management-io/ocm/pull/932?src=pr&el=tree&filepath=pkg%2Faddon%2Ftemplateagent%2Fvalues.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=open-cluster-management-io#diff-cGtnL2FkZG9uL3RlbXBsYXRlYWdlbnQvdmFsdWVzLmdv) | 50.00% | [4 Missing and 2 partials :warning: ](https://app.codecov.io/gh/open-cluster-management-io/ocm/pull/932?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=open-cluster-management-io) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main     #932      +/-   ##
==========================================
- Coverage   64.38%   64.36%   -0.02%     
==========================================
  Files         193      193              
  Lines       18926    18961      +35     
==========================================
+ Hits        12185    12204      +19     
- Misses       5743     5754      +11     
- Partials      998     1003       +5     
```

| [Flag](https://app.codecov.io/gh/open-cluster-management-io/ocm/pull/932/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=open-cluster-management-io) | Coverage Δ | |
|---|---|---|
| [unit](https://app.codecov.io/gh/open-cluster-management-io/ocm/pull/932/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=open-cluster-management-io) | `64.36% <75.92%> (-0.02%)` | :arrow_down: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=open-cluster-management-io#carryforward-flags-in-the-pull-request-comment) to find out more.

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/open-cluster-management-io/ocm/pull/932?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=open-cluster-management-io).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=open-cluster-management-io).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
</details>
author_association: NONE
user: login: zhujian7
created_at: 2025-04-02T08:21:24Z
body: /cc @elgnay @qiujian16 
author_association: MEMBER
user: login: elgnay
created_at: 2025-04-07T01:36:36Z
body: /lgtm
author_association: NONE
user: login: zhujian7
created_at: 2025-04-07T08:24:38Z
body: /cc @qiujian16 PTAL
author_association: MEMBER
user: login: qiujian16
created_at: 2025-04-08T01:37:21Z
body: /approve
author_association: MEMBER
user: login: qiujian16
created_at: 2025-04-08T01:39:39Z
body: /approve
author_association: MEMBER
user: login: openshift-ci[bot]
created_at: 2025-04-08T01:39:44Z
body: [APPROVALNOTIFIER] This PR is **APPROVED**

This pull-request has been approved by: *<a href="https://github.com/open-cluster-management-io/ocm/pull/932#issuecomment-2785014218" title="Approved">qiujian16</a>*, *<a href="https://github.com/open-cluster-management-io/ocm/pull/932#" title="Author self-approved">zhujian7</a>*

The full list of commands accepted by this bot can be found [here](https://go.k8s.io/bot-commands?repo=open-cluster-management-io%2Focm).

The pull request process is described [here](https://git.k8s.io/community/contributors/guide/owners.md#the-code-review-process)

<details >
Needs approval from an approver in each of these files:

- ~~[OWNERS](https://github.com/open-cluster-management-io/ocm/blob/main/OWNERS)~~ [qiujian16]

Approvers can indicate their approval by writing `/approve` in a comment
Approvers can cancel approval by writing `/approve cancel` in a comment
</details>
<!-- META={"approvers":[]} -->
author_association: CONTRIBUTOR
user: login: zhujian7
created_at: 2025-04-08T02:21:53Z
body: /unhold
author_association: MEMBER