Repository: clusternet/clusternet
Pull request #39 (2021-07-28 12:24:04)

shadow apiserver supports subresources
<!--  Thanks for sending a pull request!

-->

#### What type of PR is this?
kind/feature

#### What this PR does / why we need it:
- shadow apiserver supports subresources
- use manifest informer for better performance

#### Which issue(s) this PR fixes:
<!--
*Automatically closes linked issue when PR is merged.
Usage: `Fixes #<issue number>`, or `Fixes (paste link of issue)`.
-->
xref #35 

#### Special notes for your reviewer:



Patch:
diff --git a/pkg/hub/apiserver/apiserver.go b/pkg/hub/apiserver/apiserver.go
index 1821a2c..b375760 100644
--- a/pkg/hub/apiserver/apiserver.go
+++ b/pkg/hub/apiserver/apiserver.go
@@ -33,7 +33,7 @@ import (
 	"github.com/clusternet/clusternet/pkg/exchanger"
 	"github.com/clusternet/clusternet/pkg/features"
 	clusternet "github.com/clusternet/clusternet/pkg/generated/clientset/versioned"
-	clusterInformers "github.com/clusternet/clusternet/pkg/generated/informers/externalversions/clusters/v1beta1"
+	informers "github.com/clusternet/clusternet/pkg/generated/informers/externalversions"
 	shadowapiserver "github.com/clusternet/clusternet/pkg/hub/apiserver/shadow"
 	socketstorage "github.com/clusternet/clusternet/pkg/registry/proxies/socket"
 	"github.com/clusternet/clusternet/pkg/registry/proxies/socket/subresources"
@@ -110,8 +110,8 @@ func (cfg *Config) Complete() CompletedConfig {
 
 // New returns a new instance of HubAPIServer from the given config.
 func (c completedConfig) New(tunnelLogging, socketConnection bool, extraHeaderPrefixes []string,
-	mclsInformer clusterInformers.ManagedClusterInformer,
-	kubeclient *kubernetes.Clientset, clusternetclient *clusternet.Clientset) (*HubAPIServer, error) {
+	kubeclient *kubernetes.Clientset, clusternetclient *clusternet.Clientset,
+	clusternetInformerFactory informers.SharedInformerFactory) (*HubAPIServer, error) {
 	genericServer, err := c.GenericConfig.New("clusternet-hub", genericapiserver.NewEmptyDelegate())
 	if err != nil {
 		return nil, err
@@ -125,7 +125,7 @@ func (c completedConfig) New(tunnelLogging, socketConnection bool, extraHeaderPr
 
 	var ec *exchanger.Exchanger
 	if socketConnection {
-		ec = exchanger.NewExchanger(tunnelLogging, mclsInformer)
+		ec = exchanger.NewExchanger(tunnelLogging, clusternetInformerFactory.Clusters().V1beta1().ManagedClusters())
 	}
 
 	proxiesv1alpha1storage := map[string]rest.Storage{}
@@ -149,7 +149,8 @@ func (c completedConfig) New(tunnelLogging, socketConnection bool, extraHeaderPr
 					c.GenericConfig.MinRequestTimeout,
 					c.GenericConfig.AdmissionControl,
 					kubeclient,
-					clusternetclient)
+					clusternetclient,
+					clusternetInformerFactory)
 				return ss.InstallShadowAPIGroups(kubeclient.DiscoveryClient)
 			}
 		}
diff --git a/pkg/hub/apiserver/shadow/apiserver.go b/pkg/hub/apiserver/shadow/apiserver.go
index b4e7b48..8dceb8c 100644
--- a/pkg/hub/apiserver/shadow/apiserver.go
+++ b/pkg/hub/apiserver/shadow/apiserver.go
@@ -44,6 +44,7 @@ import (
 	shadowinstall "github.com/clusternet/clusternet/pkg/apis/shadow/install"
 	shadowapi "github.com/clusternet/clusternet/pkg/apis/shadow/v1alpha1"
 	clusternet "github.com/clusternet/clusternet/pkg/generated/clientset/versioned"
+	informers "github.com/clusternet/clusternet/pkg/generated/informers/externalversions"
 	"github.com/clusternet/clusternet/pkg/registry/shadow/template"
 )
 
@@ -89,19 +90,23 @@ type ShadowAPIServer struct {
 
 	kubeclient       *kubernetes.Clientset
 	clusternetclient *clusternet.Clientset
+
+	clusternetInformerFactory informers.SharedInformerFactory
 }
 
 func NewShadowAPIServer(apiserver *genericapiserver.GenericAPIServer,
 	maxRequestBodyBytes int64, minRequestTimeout int,
 	admissionControl admission.Interface,
-	kubeclient *kubernetes.Clientset, clusternetclient *clusternet.Clientset) *ShadowAPIServer {
+	kubeclient *kubernetes.Clientset, clusternetclient *clusternet.Clientset,
+	clusternetInformerFactory informers.SharedInformerFactory) *ShadowAPIServer {
 	return &ShadowAPIServer{
-		GenericAPIServer:    apiserver,
-		maxRequestBodyBytes: maxRequestBodyBytes,
-		minRequestTimeout:   minRequestTimeout,
-		admissionControl:    admissionControl,
-		kubeclient:          kubeclient,
-		clusternetclient:    clusternetclient,
+		GenericAPIServer:          apiserver,
+		maxRequestBodyBytes:       maxRequestBodyBytes,
+		minRequestTimeout:         minRequestTimeout,
+		admissionControl:          admissionControl,
+		kubeclient:                kubeclient,
+		clusternetclient:          clusternetclient,
+		clusternetInformerFactory: clusternetInformerFactory,
 	}
 }
 
@@ -131,13 +136,13 @@ func (ss *ShadowAPIServer) InstallShadowAPIGroups(cl discovery.DiscoveryInterfac
 			Scheme.AddKnownTypeWithName(schema.GroupVersion{Group: apiGroupResource.Group.Name, Version: preferredVersion}.WithKind(apiresource.Kind),
 				&unstructured.Unstructured{},
 			)
-			resourceRest := template.NewREST(ss.kubeclient, ss.clusternetclient, ParameterCodec)
+			resourceRest := template.NewREST(ss.kubeclient, ss.clusternetclient, ParameterCodec, ss.clusternetInformerFactory)
 			resourceRest.SetNamespaceScoped(apiresource.Namespaced)
 			resourceRest.SetName(apiresource.Name)
 			resourceRest.SetShortNames(apiresource.ShortNames)
 			resourceRest.SetKind(apiresource.Kind)
-			resourceRest.SetOriginalGroup(apiGroupResource.Group.Name)
-			resourceRest.SetOriginalVersion(preferredVersion)
+			resourceRest.SetGroup(apiGroupResource.Group.Name)
+			resourceRest.SetVersion(preferredVersion)
 			shadowv1alpha1storage[apiresource.Name] = resourceRest
 		}
 	}
diff --git a/pkg/hub/hub.go b/pkg/hub/hub.go
index c70285e..e683936 100644
--- a/pkg/hub/hub.go
+++ b/pkg/hub/hub.go
@@ -109,6 +109,10 @@ func NewHub(ctx context.Context, opts *options.HubServerOptions) (*Hub, error) {
 		clusternetInformerFactory.Apps().V1alpha1().HelmReleases().Informer()
 	}
 
+	if utilfeature.DefaultFeatureGate.Enabled(features.ShadowAPI) {
+		clusternetInformerFactory.Apps().V1alpha1().Manifests().Informer()
+	}
+
 	hub := &Hub{
 		ctx:                       ctx,
 		crrApprover:               approver,
@@ -162,9 +166,9 @@ func (hub *Hub) RunAPIServer() error {
 
 	server, err := config.Complete().New(hub.options.TunnelLogging, hub.socketConnection,
 		hub.options.RecommendedOptions.Authentication.RequestHeader.ExtraHeaderPrefixes,
-		hub.clusternetInformerFactory.Clusters().V1beta1().ManagedClusters(),
 		hub.kubeclient,
-		hub.clusternetclient)
+		hub.clusternetclient,
+		hub.clusternetInformerFactory)
 	if err != nil {
 		return err
 	}
diff --git a/pkg/known/labels.go b/pkg/known/labels.go
index f56d20c..cb2d2e6 100644
--- a/pkg/known/labels.go
+++ b/pkg/known/labels.go
@@ -25,11 +25,13 @@ const (
 
 	ObjectCreatedByLabel = "clusternet.io/created-by"
 
-	ConfigApiVersionLabel = "apps.clusternet.io/config.apiVersion"
-	ConfigKindLabel       = "apps.clusternet.io/config.kind"
-	ConfigNameLabel       = "apps.clusternet.io/config.name"
-	ConfigNamespaceLabel  = "apps.clusternet.io/config.namespace"
-	ConfigUIDLabel        = "apps.clusternet.io/config.uid"
+	// the source info where this object belongs to or controlled by
+	ConfigGroupLabel     = "apps.clusternet.io/config.group"
+	ConfigVersionLabel   = "apps.clusternet.io/config.version"
+	ConfigKindLabel      = "apps.clusternet.io/config.kind"
+	ConfigNameLabel      = "apps.clusternet.io/config.name"
+	ConfigNamespaceLabel = "apps.clusternet.io/config.namespace"
+	ConfigUIDLabel       = "apps.clusternet.io/config.uid"
 )
 
 // label value
diff --git a/pkg/registry/shadow/template/rest.go b/pkg/registry/shadow/template/rest.go
index 9c85b89..094bbde 100644
--- a/pkg/registry/shadow/template/rest.go
+++ b/pkg/registry/shadow/template/rest.go
@@ -42,6 +42,7 @@ import (
 
 	appsapi "github.com/clusternet/clusternet/pkg/apis/apps/v1alpha1"
 	clusternet "github.com/clusternet/clusternet/pkg/generated/clientset/versioned"
+	informers "github.com/clusternet/clusternet/pkg/generated/informers/externalversions"
 	"github.com/clusternet/clusternet/pkg/known"
 )
 
@@ -53,7 +54,7 @@ const (
 	// cluster-scoped objects will be store into Manifest with ReservedNamespace
 	ReservedNamespace = "clusternet-reserved"
 
-	// default value for DeleteCollectionWorkers
+	// default value for deleteCollectionWorkers
 	DefaultDeleteCollectionWorkers = 2
 )
 
@@ -65,22 +66,23 @@ type REST struct {
 	shortNames []string
 	// namespaced indicates if a resource is namespaced or not.
 	namespaced bool
-	// kind is the kind for the resource (e.g. 'Foo' is the kind for a resource 'foo')
+	// kind is the Kind for the resource (e.g. 'Foo' is the kind for a resource 'foo')
 	kind string
-	// originalGroup is the original group of the resource.
-	originalGroup string
-	// originalVersion is the original version of the resource.
-	originalVersion string
+	// group is the Group of the resource.
+	group string
+	// version is the Version of the resource.
+	version string
 
-	ParameterCodec runtime.ParameterCodec
+	parameterCodec runtime.ParameterCodec
 
-	DryRunClient     *kubernetes.Clientset
-	ClusternetClient *clusternet.Clientset
+	dryRunClient              *kubernetes.Clientset
+	clusternetClient          *clusternet.Clientset
+	clusternetInformerFactory informers.SharedInformerFactory
 
-	// DeleteCollectionWorkers is the maximum number of workers in a single
+	// deleteCollectionWorkers is the maximum number of workers in a single
 	// DeleteCollection call. Delete requests for the items in a collection
 	// are issued in parallel.
-	DeleteCollectionWorkers int
+	deleteCollectionWorkers int
 }
 
 // Create inserts a new item into Manifest according to the unique key from the object.
@@ -97,20 +99,21 @@ func (r *REST) Create(ctx context.Context, obj runtime.Object, createValidation
 			Name:      r.generateNameForManifest(result.GetName()),
 			Namespace: ReservedNamespace,
 			Labels: map[string]string{
-				known.ConfigApiVersionLabel: result.GetObjectKind().GroupVersionKind().GroupVersion().String(),
-				known.ConfigKindLabel:       r.kind,
-				known.ConfigNameLabel:       result.GetName(),
-				known.ConfigNamespaceLabel:  result.GetNamespace(),
+				known.ConfigGroupLabel:     r.group,
+				known.ConfigVersionLabel:   r.version,
+				known.ConfigKindLabel:      r.kind,
+				known.ConfigNameLabel:      result.GetName(),
+				known.ConfigNamespaceLabel: result.GetNamespace(),
 			},
 		},
 		Template: runtime.RawExtension{
 			Object: result,
 		},
 	}
-	manifest, err = r.ClusternetClient.AppsV1alpha1().Manifests(manifest.Namespace).Create(ctx, manifest, metav1.CreateOptions{})
+	manifest, err = r.clusternetClient.AppsV1alpha1().Manifests(manifest.Namespace).Create(ctx, manifest, metav1.CreateOptions{})
 	if err != nil {
 		if errors.IsAlreadyExists(err) {
-			return nil, errors.NewAlreadyExists(schema.GroupResource{Group: r.originalGroup, Resource: r.name}, result.GetName())
+			return nil, errors.NewAlreadyExists(schema.GroupResource{Group: r.group, Resource: r.name}, result.GetName())
 		}
 		return nil, err
 	}
@@ -119,10 +122,16 @@ func (r *REST) Create(ctx context.Context, obj runtime.Object, createValidation
 
 // Get retrieves the item from Manifest.
 func (r *REST) Get(ctx context.Context, name string, options *metav1.GetOptions) (runtime.Object, error) {
-	manifest, err := r.ClusternetClient.AppsV1alpha1().Manifests(ReservedNamespace).Get(ctx, r.generateNameForManifest(name), *options)
+	var manifest *appsapi.Manifest
+	var err error
+	if len(options.ResourceVersion) == 0 {
+		manifest, err = r.clusternetInformerFactory.Apps().V1alpha1().Manifests().Lister().Manifests(ReservedNamespace).Get(r.generateNameForManifest(name))
+	} else {
+		manifest, err = r.clusternetClient.AppsV1alpha1().Manifests(ReservedNamespace).Get(ctx, r.generateNameForManifest(name), *options)
+	}
 	if err != nil {
 		if errors.IsNotFound(err) {
-			return nil, errors.NewNotFound(schema.GroupResource{Group: r.originalGroup, Resource: r.name}, name)
+			return nil, errors.NewNotFound(schema.GroupResource{Group: r.group, Resource: r.name}, name)
 		}
 		return nil, errors.NewInternalError(err)
 	}
@@ -134,10 +143,10 @@ func (r *REST) Get(ctx context.Context, name string, options *metav1.GetOptions)
 // or an error. If the registry allows create-on-update, the create flow will be executed.
 // A bool is returned along with the object and any errors, to indicate object creation.
 func (r *REST) Update(ctx context.Context, name string, objInfo rest.UpdatedObjectInfo, createValidation rest.ValidateObjectFunc, updateValidation rest.ValidateObjectUpdateFunc, forceAllowCreate bool, options *metav1.UpdateOptions) (runtime.Object, bool, error) {
-	manifest, err := r.ClusternetClient.AppsV1alpha1().Manifests(ReservedNamespace).Get(ctx, r.generateNameForManifest(name), metav1.GetOptions{})
+	manifest, err := r.clusternetInformerFactory.Apps().V1alpha1().Manifests().Lister().Manifests(ReservedNamespace).Get(r.generateNameForManifest(name))
 	if err != nil {
 		if errors.IsNotFound(err) {
-			return nil, false, errors.NewNotFound(schema.GroupResource{Group: r.originalGroup, Resource: r.name}, name)
+			return nil, false, errors.NewNotFound(schema.GroupResource{Group: r.group, Resource: r.name}, name)
 		}
 		return nil, false, errors.NewInternalError(err)
 	}
@@ -161,7 +170,7 @@ func (r *REST) Update(ctx context.Context, name string, objInfo rest.UpdatedObje
 
 	manifest.Template.Reset()
 	manifest.Template.Object = result
-	manifest, err = r.ClusternetClient.AppsV1alpha1().Manifests(ReservedNamespace).Update(ctx, manifest, *options)
+	manifest, err = r.clusternetClient.AppsV1alpha1().Manifests(ReservedNamespace).Update(ctx, manifest, *options)
 	if err != nil {
 		return nil, false, err
 	}
@@ -173,10 +182,10 @@ func (r *REST) Update(ctx context.Context, name string, objInfo rest.UpdatedObje
 // Delete removes the item from storage.
 // options can be mutated by rest.BeforeDelete due to a graceful deletion strategy.
 func (r *REST) Delete(ctx context.Context, name string, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions) (runtime.Object, bool, error) {
-	err := r.ClusternetClient.AppsV1alpha1().Manifests(ReservedNamespace).Delete(ctx, r.generateNameForManifest(name), *options)
+	err := r.clusternetClient.AppsV1alpha1().Manifests(ReservedNamespace).Delete(ctx, r.generateNameForManifest(name), *options)
 	if err != nil {
 		if errors.IsNotFound(err) {
-			err = errors.NewNotFound(schema.GroupResource{Group: r.originalGroup, Resource: r.name}, name)
+			err = errors.NewNotFound(schema.GroupResource{Group: r.group, Resource: r.name}, name)
 		}
 	}
 	return nil, err == nil, err
@@ -210,9 +219,9 @@ func (r *REST) DeleteCollection(ctx context.Context, deleteValidation rest.Valid
 	// Spawn a number of goroutines, so that we can issue requests to storage
 	// in parallel to speed up deletion.
 	// It is proportional to the number of items to delete, up to
-	// DeleteCollectionWorkers (it doesn't make much sense to spawn 16
+	// deleteCollectionWorkers (it doesn't make much sense to spawn 16
 	// workers to delete 10 items).
-	workersNumber := r.DeleteCollectionWorkers
+	workersNumber := r.deleteCollectionWorkers
 	if workersNumber > len(items) {
 		workersNumber = len(items)
 	}
@@ -277,7 +286,7 @@ func (r *REST) Watch(ctx context.Context, options *internalversion.ListOptions)
 	}
 
 	klog.V(5).Infof("%v", label)
-	watcher, err := r.ClusternetClient.AppsV1alpha1().Manifests(ReservedNamespace).Watch(ctx, metav1.ListOptions{
+	watcher, err := r.clusternetClient.AppsV1alpha1().Manifests(ReservedNamespace).Watch(ctx, metav1.ListOptions{
 		TypeMeta:             metav1.TypeMeta{},
 		LabelSelector:        label.String(),
 		FieldSelector:        "",
@@ -319,7 +328,7 @@ func (r *REST) List(ctx context.Context, options *internalversion.ListOptions) (
 		return nil, err
 	}
 
-	manifests, err := r.ClusternetClient.AppsV1alpha1().Manifests(ReservedNamespace).List(ctx, metav1.ListOptions{
+	manifests, err := r.clusternetClient.AppsV1alpha1().Manifests(ReservedNamespace).List(ctx, metav1.ListOptions{
 		//TypeMeta:             metav1.TypeMeta{},
 		LabelSelector: label.String(),
 		//FieldSelector:        "",
@@ -362,7 +371,7 @@ func (r *REST) NewList() runtime.Object {
 }
 
 func (r *REST) ConvertToTable(ctx context.Context, object runtime.Object, tableOptions runtime.Object) (*metav1.Table, error) {
-	tableConvertor := rest.NewDefaultTableConvertor(schema.GroupResource{Group: r.originalGroup, Resource: r.name})
+	tableConvertor := rest.NewDefaultTableConvertor(schema.GroupResource{Group: r.group, Resource: r.name})
 	return tableConvertor.ConvertToTable(ctx, object, tableOptions)
 }
 
@@ -390,12 +399,12 @@ func (r *REST) Categories() []string {
 	return []string{category}
 }
 
-func (r *REST) SetOriginalGroup(originalGroup string) {
-	r.originalGroup = originalGroup
+func (r *REST) SetGroup(group string) {
+	r.group = group
 }
 
-func (r *REST) SetOriginalVersion(originalVersion string) {
-	r.originalVersion = originalVersion
+func (r *REST) SetVersion(version string) {
+	r.version = version
 }
 
 func (r *REST) SetKind(kind string) {
@@ -413,17 +422,17 @@ func (r *REST) New() runtime.Object {
 func (r *REST) GroupVersionKind(_ schema.GroupVersion) schema.GroupVersionKind {
 	// use original GVK
 	return schema.GroupVersionKind{
-		Group:   r.originalGroup,
-		Version: r.originalVersion,
+		Group:   r.group,
+		Version: r.version,
 		Kind:    r.kind,
 	}
 }
 
 func (r *REST) normalizeRequest(req *clientgorest.Request, namespace string) *clientgorest.Request {
-	if len(r.originalGroup) == 0 {
-		req.Prefix(CoreGroupPrefix, r.originalVersion)
+	if len(r.group) == 0 {
+		req.Prefix(CoreGroupPrefix, r.version)
 	} else {
-		req.Prefix(NamedGroupPrefix, r.originalGroup, r.originalVersion)
+		req.Prefix(NamedGroupPrefix, r.group, r.version)
 	}
 	if r.namespaced {
 		req.Namespace(namespace)
@@ -432,7 +441,7 @@ func (r *REST) normalizeRequest(req *clientgorest.Request, namespace string) *cl
 }
 
 func (r *REST) generateNameForManifest(name string) string {
-	return fmt.Sprintf("%s-%s", strings.ToLower(r.kind), name)
+	return fmt.Sprintf("%s-%s", r.getResourceName(), name)
 }
 
 func (r *REST) dryRunCreate(ctx context.Context, obj runtime.Object, createValidation rest.ValidateObjectFunc, options *metav1.CreateOptions) (*unstructured.Unstructured, error) {
@@ -462,14 +471,14 @@ func (r *REST) dryRunCreate(ctx context.Context, obj runtime.Object, createValid
 		return nil, errors.NewBadRequest(fmt.Sprintf("failed to marshal to json: %v", u.Object))
 	}
 
-	client := r.DryRunClient.RESTClient()
+	client := r.dryRunClient.RESTClient()
 	result := &unstructured.Unstructured{}
 	klog.V(7).Infof("creating %s with %s", r.kind, body)
 	// first we dry-run the creation
 	req := client.Post().
-		Resource(r.name).
+		Resource(r.getResourceName()).
 		Param("dryRun", "All").
-		VersionedParams(options, r.ParameterCodec).
+		VersionedParams(options, r.parameterCodec).
 		Body(body)
 	err = r.normalizeRequest(req, dryRunNamespace).Do(ctx).Into(result)
 	if err != nil && errors.IsAlreadyExists(err) {
@@ -477,8 +486,8 @@ func (r *REST) dryRunCreate(ctx context.Context, obj runtime.Object, createValid
 		// get existing object
 		req := client.Get().
 			Name(u.GetName()).
-			Resource(r.name).
-			VersionedParams(options, r.ParameterCodec).
+			Resource(r.getResourceName()).
+			VersionedParams(options, r.parameterCodec).
 			Body(body)
 		err = r.normalizeRequest(req, dryRunNamespace).Do(ctx).Into(result)
 	}
@@ -525,15 +534,26 @@ func (r *REST) convertListOptionsToLabels(options *internalversion.ListOptions)
 	return label, nil
 }
 
+func (r *REST) getResourceName() string {
+	resourceName := r.name
+	// is subresource
+	if strings.Contains(resourceName, "/") {
+		resourceName = strings.Split(resourceName, "/")[0]
+	}
+	return resourceName
+}
+
 // NewREST returns a RESTStorage object that will work against API services.
-func NewREST(dryRunClient *kubernetes.Clientset, clusternetclient *clusternet.Clientset, parameterCodec runtime.ParameterCodec) *REST {
+func NewREST(dryRunClient *kubernetes.Clientset, clusternetclient *clusternet.Clientset, parameterCodec runtime.ParameterCodec,
+	clusternetInformerFactory informers.SharedInformerFactory) *REST {
 	return &REST{
-		DryRunClient:     dryRunClient,
-		ClusternetClient: clusternetclient,
-		ParameterCodec:   parameterCodec,
-		// currently we only set a default value for DeleteCollectionWorkers
+		dryRunClient:              dryRunClient,
+		clusternetClient:          clusternetclient,
+		clusternetInformerFactory: clusternetInformerFactory,
+		parameterCodec:            parameterCodec,
+		// currently we only set a default value for deleteCollectionWorkers
 		// TODO: make it configurable?
-		DeleteCollectionWorkers: DefaultDeleteCollectionWorkers,
+		deleteCollectionWorkers: DefaultDeleteCollectionWorkers,
 	}
 }
 


Test patch:
diff --git a/pkg/registry/shadow/template/transformer_test.go b/pkg/registry/shadow/template/transformer_test.go
new file mode 100644
index 0000000..717c7f2
--- /dev/null
+++ b/pkg/registry/shadow/template/transformer_test.go
@@ -0,0 +1,104 @@
+/*
+Copyright 2021 The Clusternet 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 template
+
+import (
+	"strings"
+	"testing"
+
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/runtime"
+
+	appsapi "github.com/clusternet/clusternet/pkg/apis/apps/v1alpha1"
+	"github.com/clusternet/clusternet/pkg/known"
+)
+
+func TestTransformManifest(t *testing.T) {
+	tests := []struct {
+		name         string
+		manifest     *appsapi.Manifest
+		wantedString string
+		wantErr      bool
+	}{
+		{
+			manifest: &appsapi.Manifest{
+				TypeMeta: metav1.TypeMeta{
+					APIVersion: appsapi.SchemeGroupVersion.String(),
+					Kind:       appsapi.Kind("Manifest").String(),
+				},
+				ObjectMeta: metav1.ObjectMeta{
+					Generation: 6,
+					Labels: map[string]string{
+						known.ConfigGroupLabel:     "foo",
+						known.ConfigVersionLabel:   "v1alpha1",
+						known.ConfigKindLabel:      "Bar",
+						known.ConfigNameLabel:      "boo",
+						known.ConfigNamespaceLabel: "ns1",
+					},
+					Namespace:       ReservedNamespace,
+					Name:            "bars-boo",
+					ResourceVersion: "1860247",
+					UID:             "13ff776c-1e91-4a84-b77d-6c35f3a52fed",
+				},
+				Template: runtime.RawExtension{
+					Raw: []byte(`
+{
+  "apiVersion": "v1",
+  "kind": "Namespace",
+  "metadata": {
+    "labels": {
+      "clusternet.io/created-by": "clusternet-hub",
+      "key": "valxyz",
+      "kubernetes.io/metadata.name": "abc"
+    },
+    "name": "abc",
+    "uid": "01b17d4e-18cf-441d-8ee6-1c484b4afbe2"
+  },
+  "spec": {
+    "finalizers": [
+      "kubernetes"
+    ]
+  },
+  "status": {
+    "phase": "Active"
+  }
+}
+`),
+				},
+			},
+			wantedString: `{"apiVersion":"v1","kind":"Namespace","metadata":{"generation":6,"labels":{"clusternet.io/created-by":"clusternet-hub","key":"valxyz","kubernetes.io/metadata.name":"abc"},"name":"abc","resourceVersion":"1860247","uid":"13ff776c-1e91-4a84-b77d-6c35f3a52fed"},"spec":{"finalizers":["kubernetes"]},"status":{"phase":"Active"}}`,
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got, err := transformManifest(tt.manifest)
+			if (err != nil) != tt.wantErr {
+				t.Errorf("transformManifest() error = %v, wantErr %v", err, tt.wantErr)
+				return
+			}
+
+			gotString, err := got.MarshalJSON()
+			if err != nil {
+				t.Errorf("failed to marshal: %v", err)
+				return
+			}
+			if strings.TrimRight(string(gotString), "\n") != tt.wantedString {
+				t.Errorf("transformManifest() \ngot = \n%q\n, want = \n%q", gotString, tt.wantedString)
+			}
+		})
+	}
+}