Repository: kubeclipper/kubeclipper
Pull request #420 (2022-11-23 08:40:18)

fix: support query global resource in project
Signed-off-by: lixd <xueduan.li@gmail.com>

<!-- Thanks for sending a pull request! Here are some tips for you:

1. If you want **faster** PR reviews, read how: https://github.com/kubeclipper/community/blob/master/developer-guide/development/the-pr-author-guide-to-getting-through-code-review.md
2. In case you want to know how your PR got reviewed, read: https://github.com/kubeclipper/community/blob/master/developer-guide/development/code-review-guide.md
3. Here are some coding convetions followed by Kubeclipper community: https://github.com/kubeclipper/community/blob/master/developer-guide/development/coding-conventions.md
-->

### What type of PR is this?
<!--
Add one of the following kinds:
/kind bug
/kind cleanup
/kind documentation
/kind feature
/kind design

Optionally add one or more of the following kinds if applicable:
/kind api-change
/kind deprecation
/kind failing-test
/kind flake
/kind regression
-->

/kind bug
### What this PR does / why we need it:
support query global resource in project
### Which issue(s) this PR fixes:
<!--
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 reviewers:
```
add api to support query global resource in project, query global resource and project resource at the same time
```

### Does this PR introduced 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://github.com/kubernetes/community/blob/master/contributors/guide/release-notes.md
-->
```release-note
None
```

### Additional documentation, usage docs, etc.:
<!--
This section can be blank if this pull request does not require a release note.
Please use the following format for linking documentation or pass the
section below:
- [KEP]: <link>
- [Usage]: <link>
- [Other doc]: <link>
-->
```docs

```

kubeclipper-bot: [APPROVALNOTIFIER] This PR is **NOT APPROVED**

This pull-request has been approved by: *<a href="https://github.com/kubeclipper/kubeclipper/pull/420#" title="Author self-approved">lixd</a>*
To complete the [pull request process](https://git.k8s.io/community/contributors/guide/owners.md#the-code-review-process), please assign **x893675** after the PR has been reviewed.
You can assign the PR to them by writing `/assign @x893675` in a comment when ready.

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

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

- **[OWNERS](https://github.com/kubeclipper/kubeclipper/blob/master/OWNERS)**

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":["x893675"]} -->
codecov-commenter: # [Codecov](https://codecov.io/gh/kubeclipper/kubeclipper/pull/420?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubeclipper) Report
> Merging [#420](https://codecov.io/gh/kubeclipper/kubeclipper/pull/420?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubeclipper) (3b62f2a) into [master](https://codecov.io/gh/kubeclipper/kubeclipper/commit/8e228268d6c57d76a17a6bc4da35fbc955752887?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubeclipper) (8e22826) will **decrease** coverage by `0.00%`.
> The diff coverage is `13.77%`.

[![Impacted file tree graph](https://codecov.io/gh/kubeclipper/kubeclipper/pull/420/graphs/tree.svg?width=650&height=150&src=pr&token=3PWFG2MHTE&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubeclipper)](https://codecov.io/gh/kubeclipper/kubeclipper/pull/420?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubeclipper)

```diff
@@            Coverage Diff             @@
##           master     #420      +/-   ##
==========================================
- Coverage   12.10%   12.10%   -0.01%     
==========================================
  Files         106      109       +3     
  Lines       17102    17596     +494     
==========================================
+ Hits         2071     2130      +59     
- Misses      14783    15217     +434     
- Partials      248      249       +1     
```


| [Impacted Files](https://codecov.io/gh/kubeclipper/kubeclipper/pull/420?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubeclipper) | Coverage Δ | |
|---|---|---|
| [pkg/apis/core/v1/handler.go](https://codecov.io/gh/kubeclipper/kubeclipper/pull/420/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubeclipper#diff-cGtnL2FwaXMvY29yZS92MS9oYW5kbGVyLmdv) | `0.34% <0.00%> (-0.03%)` | :arrow_down: |
| [pkg/apis/core/v1/registry.go](https://codecov.io/gh/kubeclipper/kubeclipper/pull/420/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubeclipper#diff-cGtnL2FwaXMvY29yZS92MS9yZWdpc3RyeS5nbw==) | `0.00% <0.00%> (ø)` | |
| [pkg/query/query.go](https://codecov.io/gh/kubeclipper/kubeclipper/pull/420/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubeclipper#diff-cGtnL3F1ZXJ5L3F1ZXJ5Lmdv) | `21.42% <27.27%> (+10.69%)` | :arrow_up: |
| [pkg/server/restplus/multi\_watch.go](https://codecov.io/gh/kubeclipper/kubeclipper/pull/420/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubeclipper#diff-cGtnL3NlcnZlci9yZXN0cGx1cy9tdWx0aV93YXRjaC5nbw==) | `92.30% <92.30%> (ø)` | |
| [pkg/server/restplus/watch.go](https://codecov.io/gh/kubeclipper/kubeclipper/pull/420/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubeclipper#diff-cGtnL3NlcnZlci9yZXN0cGx1cy93YXRjaC5nbw==) | `0.00% <0.00%> (ø)` | |
| [pkg/server/restplus/errors.go](https://codecov.io/gh/kubeclipper/kubeclipper/pull/420/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubeclipper#diff-cGtnL3NlcnZlci9yZXN0cGx1cy9lcnJvcnMuZ28=) | `0.00% <0.00%> (ø)` | |

lixd: /assign @x893675
x893675: /lgtm
/approve
kubeclipper-bot: LGTM label has been added.  <details>Git tree hash: 5e30adedda659826c81b6f304d2baca8e2c5accd</details>
kubeclipper-bot: [APPROVALNOTIFIER] This PR is **APPROVED**

This pull-request has been approved by: *<a href="https://github.com/kubeclipper/kubeclipper/pull/420#" title="Author self-approved">lixd</a>*, *<a href="https://github.com/kubeclipper/kubeclipper/pull/420#issuecomment-1325841596" title="Approved">x893675</a>*

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

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/kubeclipper/kubeclipper/blob/master/OWNERS)~~ [x893675]

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":[]} -->

Patch:
diff --git a/api/openapi-spec/swagger.json b/api/openapi-spec/swagger.json
index 92bb3a7..2275afe 100644
--- a/api/openapi-spec/swagger.json
+++ b/api/openapi-spec/swagger.json
@@ -4029,7 +4029,7 @@
           "Core-Cluster"
         ],
         "summary": "List domains api for project.",
-        "operationId": "ListDomains",
+        "operationId": "ListDomainsProject",
         "parameters": [
           {
             "type": "string",
@@ -4648,7 +4648,7 @@
           "Core-Cluster"
         ],
         "summary": "List Registries api for projects.",
-        "operationId": "ListRegistry",
+        "operationId": "ListRegistryProject",
         "parameters": [
           {
             "type": "string",
@@ -4711,7 +4711,7 @@
           "Core-Cluster"
         ],
         "summary": "List templates api for project.",
-        "operationId": "ListTemplates",
+        "operationId": "ListTemplatesProject",
         "parameters": [
           {
             "type": "string",
@@ -4756,6 +4756,46 @@
             }
           }
         }
+      },
+      "post": {
+        "produces": [
+          "application/json"
+        ],
+        "tags": [
+          "Core-Cluster"
+        ],
+        "summary": "Create template.",
+        "operationId": "CreateTemplate",
+        "parameters": [
+          {
+            "name": "body",
+            "in": "body",
+            "required": true,
+            "schema": {
+              "$ref": "#/definitions/v1.Template"
+            }
+          }
+        ],
+        "responses": {
+          "200": {
+            "description": "OK",
+            "schema": {
+              "$ref": "#/definitions/v1.Template"
+            }
+          },
+          "400": {
+            "description": "Bad Request",
+            "schema": {
+              "$ref": "#/definitions/errors.HTTPError"
+            }
+          },
+          "500": {
+            "description": "Internal Server Error",
+            "schema": {
+              "$ref": "#/definitions/errors.HTTPError"
+            }
+          }
+        }
       }
     },
     "/api/core.kubeclipper.io/v1/regions": {
diff --git a/pkg/apis/core/v1/handler.go b/pkg/apis/core/v1/handler.go
index e46105e..79d03b2 100644
--- a/pkg/apis/core/v1/handler.go
+++ b/pkg/apis/core/v1/handler.go
@@ -38,9 +38,11 @@ import (
 	"go.uber.org/zap"
 	apimachineryErrors "k8s.io/apimachinery/pkg/api/errors"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/runtime"
 	"k8s.io/apimachinery/pkg/util/json"
 	r "k8s.io/apimachinery/pkg/util/rand"
 	"k8s.io/apimachinery/pkg/util/sets"
+	"k8s.io/apimachinery/pkg/watch"
 	"k8s.io/client-go/tools/clientcmd"
 	"k8s.io/client-go/tools/remotecommand"
 
@@ -2286,21 +2288,6 @@ func (h *handler) DescribeLease(request *restful.Request, response *restful.Resp
 
 func (h *handler) ListDomains(request *restful.Request, response *restful.Response) {
 	q := query.ParseQueryParameter(request)
-	ctx := request.Request.Context()
-
-	info, _ := reqpkg.InfoFrom(ctx)
-	if info.IsProjectScope() && !q.HasLabelSelector(fmt.Sprintf("!%s", common.LabelProject)) {
-		project := request.PathParameter("project")
-		if _, err := h.tenantOperator.GetProject(ctx, project); err != nil {
-			if apimachineryErrors.IsNotFound(err) {
-				restplus.HandleNotFound(response, request, err)
-				return
-			}
-			restplus.HandleInternalError(response, request, err)
-			return
-		}
-		q.AddLabelSelector([]string{fmt.Sprintf("%s=%s", common.LabelProject, project)})
-	}
 
 	if q.Watch {
 		h.watchDomain(request, response, q)
@@ -2341,6 +2328,69 @@ func (h *handler) watchDomain(req *restful.Request, resp *restful.Response, q *q
 	restplus.ServeWatch(watcher, v1.SchemeGroupVersion.WithKind("Domain"), req, resp, timeout)
 }
 
+func (h *handler) ListDomainsProject(request *restful.Request, response *restful.Response) {
+	q := query.ParseQueryParameter(request)
+	ctx := request.Request.Context()
+
+	project := request.PathParameter("project")
+	if _, err := h.tenantOperator.GetProject(ctx, project); err != nil {
+		if apimachineryErrors.IsNotFound(err) {
+			restplus.HandleNotFound(response, request, err)
+			return
+		}
+		restplus.HandleInternalError(response, request, err)
+		return
+	}
+	globalQuery := q.DeepCopy()
+	globalQuery.AddLabelSelector([]string{fmt.Sprintf("!%s", common.LabelProject)})
+	projectQuery := q.DeepCopy()
+	projectQuery.AddLabelSelector([]string{fmt.Sprintf("%s=%s", common.LabelProject, project)})
+
+	if q.Watch {
+		h.watchDomainProject(request, response, []*query.Query{globalQuery, projectQuery})
+		return
+	}
+
+	global, err := h.clusterOperator.ListDomains(request.Request.Context(), globalQuery)
+	if err != nil {
+		restplus.HandleInternalError(response, request, err)
+		return
+	}
+	projectRet, err := h.clusterOperator.ListDomains(request.Request.Context(), projectQuery)
+	if err != nil {
+		restplus.HandleInternalError(response, request, err)
+		return
+	}
+	result, err := models.DefaultListMerge([]runtime.Object{global.DeepCopyObject(), projectRet.DeepCopyObject()}, cluster.DNSFuzzyFilter, nil, nil)
+	if err != nil {
+		restplus.HandleInternalError(response, request, err)
+		return
+	}
+	_ = response.WriteHeaderAndEntity(http.StatusOK, result)
+}
+
+func (h *handler) watchDomainProject(req *restful.Request, resp *restful.Response, querys []*query.Query) {
+	watchers := make([]watch.Interface, 0, len(querys))
+	timeout := time.Duration(0)
+	for _, q := range querys {
+		if q.TimeoutSeconds != nil {
+			timeout = time.Duration(*q.TimeoutSeconds) * time.Second
+		}
+		if timeout == 0 {
+			timeout = time.Duration(float64(query.MinTimeoutSeconds) * (rand.Float64() + 1.0))
+		}
+
+		watcher, err := h.clusterOperator.WatchDomain(req.Request.Context(), q)
+		if err != nil {
+			restplus.HandleInternalError(resp, req, err)
+			return
+		}
+		watchers = append(watchers, watcher)
+	}
+	multiWatcher := restplus.NewMultiWatcher(watchers)
+	restplus.ServeWatch(multiWatcher, v1.SchemeGroupVersion.WithKind("Domain"), req, resp, timeout)
+}
+
 func (h *handler) CheckDomainExists(request *restful.Request, response *restful.Response) {
 	q := query.ParseQueryParameter(request)
 	result, err := h.clusterOperator.ListDomainsEx(request.Request.Context(), q)
@@ -2930,28 +2980,98 @@ func decodeMsgToSSH(msg string) (*SSHCredential, error) {
 }
 
 func (h *handler) ListTemplates(request *restful.Request, response *restful.Response) {
+	q := query.ParseQueryParameter(request)
+	if q.Watch {
+		h.watchTemplates(request, response, q)
+		return
+	}
+	templates, err := h.clusterOperator.ListTemplatesEx(request.Request.Context(), q)
+	if err != nil {
+		restplus.HandleInternalError(response, request, err)
+		return
+	}
+	_ = response.WriteHeaderAndEntity(http.StatusOK, templates)
+}
+
+func (h *handler) watchTemplates(req *restful.Request, resp *restful.Response, q *query.Query) {
+	timeout := time.Duration(0)
+	if q.TimeoutSeconds != nil {
+		timeout = time.Duration(*q.TimeoutSeconds) * time.Second
+	}
+	if timeout == 0 {
+		timeout = time.Duration(float64(query.MinTimeoutSeconds) * (rand.Float64() + 1.0))
+	}
+
+	watcher, err := h.clusterOperator.WatchTemplates(req.Request.Context(), q)
+	if err != nil {
+		restplus.HandleInternalError(resp, req, err)
+		return
+	}
+	restplus.ServeWatch(watcher, v1.SchemeGroupVersion.WithKind("Templates"), req, resp, timeout)
+}
+
+func (h *handler) ListTemplatesProject(request *restful.Request, response *restful.Response) {
 	q := query.ParseQueryParameter(request)
 	ctx := request.Request.Context()
-	info, _ := reqpkg.InfoFrom(ctx)
-	if info.IsProjectScope() && !q.HasLabelSelector(fmt.Sprintf("!%s", common.LabelProject)) {
-		project := request.PathParameter("project")
-		if _, err := h.tenantOperator.GetProject(ctx, project); err != nil {
-			if apimachineryErrors.IsNotFound(err) {
-				restplus.HandleNotFound(response, request, err)
-				return
-			}
-			restplus.HandleInternalError(response, request, err)
+
+	project := request.PathParameter("project")
+	if _, err := h.tenantOperator.GetProject(ctx, project); err != nil {
+		if apimachineryErrors.IsNotFound(err) {
+			restplus.HandleNotFound(response, request, err)
 			return
 		}
-		q.AddLabelSelector([]string{fmt.Sprintf("%s=%s", common.LabelProject, project)})
+		restplus.HandleInternalError(response, request, err)
+		return
 	}
 
-	templates, err := h.clusterOperator.ListTemplatesEx(request.Request.Context(), q)
+	globalQuery := q.DeepCopy()
+	globalQuery.AddLabelSelector([]string{fmt.Sprintf("!%s", common.LabelProject)})
+	projectQuery := q.DeepCopy()
+	projectQuery.AddLabelSelector([]string{fmt.Sprintf("%s=%s", common.LabelProject, project)})
+
+	if q.Watch {
+		h.watchTemplatesProject(request, response, []*query.Query{globalQuery, projectQuery})
+		return
+	}
+
+	global, err := h.clusterOperator.ListTemplates(request.Request.Context(), globalQuery)
 	if err != nil {
 		restplus.HandleInternalError(response, request, err)
 		return
 	}
-	_ = response.WriteHeaderAndEntity(http.StatusOK, templates)
+	projectRet, err := h.clusterOperator.ListTemplates(request.Request.Context(), projectQuery)
+	if err != nil {
+		restplus.HandleInternalError(response, request, err)
+		return
+	}
+	result, err := models.DefaultListMerge([]runtime.Object{global.DeepCopyObject(), projectRet.DeepCopyObject()}, cluster.TemplateFuzzyFilter, nil, nil)
+	if err != nil {
+		restplus.HandleInternalError(response, request, err)
+		return
+	}
+	_ = response.WriteHeaderAndEntity(http.StatusOK, result)
+}
+
+func (h *handler) watchTemplatesProject(req *restful.Request, resp *restful.Response, querys []*query.Query) {
+	watchers := make([]watch.Interface, 0, len(querys))
+	timeout := time.Duration(0)
+	for _, q := range querys {
+		if q.TimeoutSeconds != nil {
+			timeout = time.Duration(*q.TimeoutSeconds) * time.Second
+		}
+		if timeout == 0 {
+			timeout = time.Duration(float64(query.MinTimeoutSeconds) * (rand.Float64() + 1.0))
+		}
+
+		watcher, err := h.clusterOperator.WatchTemplates(req.Request.Context(), q)
+		if err != nil {
+			restplus.HandleInternalError(resp, req, err)
+			return
+		}
+		watchers = append(watchers, watcher)
+	}
+	multiWatcher := restplus.NewMultiWatcher(watchers)
+	restplus.ServeWatch(multiWatcher, v1.SchemeGroupVersion.WithKind("Templates"), req, resp, timeout)
 }
 
 func (h *handler) DescribeTemplate(request *restful.Request, response *restful.Response) {
@@ -3119,6 +3239,70 @@ func (h *handler) watchBackupPoints(req *restful.Request, resp *restful.Response
 	restplus.ServeWatch(watcher, v1.SchemeGroupVersion.WithKind("BackupPoint"), req, resp, timeout)
 }
 
+func (h *handler) ListBackupPointsProject(req *restful.Request, resp *restful.Response) {
+	q := query.ParseQueryParameter(req)
+	ctx := req.Request.Context()
+
+	project := req.PathParameter("project")
+	if _, err := h.tenantOperator.GetProject(ctx, project); err != nil {
+		if apimachineryErrors.IsNotFound(err) {
+			restplus.HandleNotFound(resp, req, err)
+			return
+		}
+		restplus.HandleInternalError(resp, req, err)
+		return
+	}
+
+	globalQuery := q.DeepCopy()
+	globalQuery.AddLabelSelector([]string{fmt.Sprintf("!%s", common.LabelProject)})
+	projectQuery := q.DeepCopy()
+	projectQuery.AddLabelSelector([]string{fmt.Sprintf("%s=%s", common.LabelProject, project)})
+
+	if q.Watch {
+		h.watchBackupPointsProject(req, resp, []*query.Query{globalQuery, projectQuery})
+		return
+	}
+
+	global, err := h.clusterOperator.ListBackupPoints(ctx, globalQuery)
+	if err != nil {
+		restplus.HandleInternalError(resp, req, err)
+		return
+	}
+	projectRet, err := h.clusterOperator.ListBackupPoints(ctx, projectQuery)
+	if err != nil {
+		restplus.HandleInternalError(resp, req, err)
+		return
+	}
+	result, err := models.DefaultListMerge([]runtime.Object{global.DeepCopyObject(), projectRet.DeepCopyObject()}, cluster.BackupPointFuzzyFilter, nil, nil)
+	if err != nil {
+		restplus.HandleInternalError(resp, req, err)
+		return
+	}
+	_ = resp.WriteHeaderAndEntity(http.StatusOK, result)
+}
+
+func (h *handler) watchBackupPointsProject(req *restful.Request, resp *restful.Response, querys []*query.Query) {
+	watchers := make([]watch.Interface, 0, len(querys))
+	timeout := time.Duration(0)
+	for _, q := range querys {
+		if q.TimeoutSeconds != nil {
+			timeout = time.Duration(*q.TimeoutSeconds) * time.Second
+		}
+		if timeout == 0 {
+			timeout = time.Duration(float64(query.MinTimeoutSeconds) * (rand.Float64() + 1.0))
+		}
+
+		watcher, err := h.clusterOperator.WatchBackupPoints(req.Request.Context(), q)
+		if err != nil {
+			restplus.HandleInternalError(resp, req, err)
+			return
+		}
+		watchers = append(watchers, watcher)
+	}
+	multiWatcher := restplus.NewMultiWatcher(watchers)
+	restplus.ServeWatch(multiWatcher, v1.SchemeGroupVersion.WithKind("BackupPoint"), req, resp, timeout)
+}
+
 func (h *handler) CreateBackupPoint(request *restful.Request, response *restful.Response) {
 	bp := new(v1.BackupPoint)
 	if err := request.ReadEntity(bp); err != nil {
@@ -3959,6 +4143,69 @@ func (h *handler) watchRegistries(req *restful.Request, resp *restful.Response,
 	restplus.ServeWatch(watcher, v1.SchemeGroupVersion.WithKind("Registry"), req, resp, timeout)
 }
 
+func (h *handler) ListRegistryProject(req *restful.Request, resp *restful.Response) {
+	q := query.ParseQueryParameter(req)
+	ctx := req.Request.Context()
+
+	project := req.PathParameter("project")
+	if _, err := h.tenantOperator.GetProject(ctx, project); err != nil {
+		if apimachineryErrors.IsNotFound(err) {
+			restplus.HandleNotFound(resp, req, err)
+			return
+		}
+		restplus.HandleInternalError(resp, req, err)
+		return
+	}
+
+	globalQuery := q.DeepCopy()
+	globalQuery.AddLabelSelector([]string{fmt.Sprintf("!%s", common.LabelProject)})
+	projectQuery := q.DeepCopy()
+	projectQuery.AddLabelSelector([]string{fmt.Sprintf("%s=%s", common.LabelProject, project)})
+	if q.Watch {
+		h.watchRegistriesProject(req, resp, []*query.Query{globalQuery, projectQuery})
+		return
+	}
+
+	global, err := h.clusterOperator.ListRegistries(ctx, globalQuery)
+	if err != nil {
+		restplus.HandleInternalError(resp, req, err)
+		return
+	}
+	projectRet, err := h.clusterOperator.ListRegistries(ctx, projectQuery)
+	if err != nil {
+		restplus.HandleInternalError(resp, req, err)
+		return
+	}
+	result, err := models.DefaultListMerge([]runtime.Object{global.DeepCopyObject(), projectRet.DeepCopyObject()}, cluster.RegistryFuzzyFilter, nil, nil)
+	if err != nil {
+		restplus.HandleInternalError(resp, req, err)
+		return
+	}
+	_ = resp.WriteHeaderAndEntity(http.StatusOK, result)
+}
+
+func (h *handler) watchRegistriesProject(req *restful.Request, resp *restful.Response, querys []*query.Query) {
+	watchers := make([]watch.Interface, 0, len(querys))
+	timeout := time.Duration(0)
+	for _, q := range querys {
+		if q.TimeoutSeconds != nil {
+			timeout = time.Duration(*q.TimeoutSeconds) * time.Second
+		}
+		if timeout == 0 {
+			timeout = time.Duration(float64(query.MinTimeoutSeconds) * (rand.Float64() + 1.0))
+		}
+
+		watcher, err := h.clusterOperator.WatchRegistries(req.Request.Context(), q)
+		if err != nil {
+			restplus.HandleInternalError(resp, req, err)
+			return
+		}
+		watchers = append(watchers, watcher)
+	}
+	multiWatcher := restplus.NewMultiWatcher(watchers)
+	restplus.ServeWatch(multiWatcher, v1.SchemeGroupVersion.WithKind("Registry"), req, resp, timeout)
+}
+
 func (h *handler) DeleteRegistry(req *restful.Request, resp *restful.Response) {
 	name := req.PathParameter("name")
 	dryRun := query.GetBoolValueWithDefault(req, query.ParamDryRun, false)
diff --git a/pkg/apis/core/v1/registry.go b/pkg/apis/core/v1/registry.go
index ff2ad5a..b4c957a 100644
--- a/pkg/apis/core/v1/registry.go
+++ b/pkg/apis/core/v1/registry.go
@@ -941,8 +941,9 @@ func SetupWebService(h *handler) *restful.WebService {
 			Required(false)).
 		Returns(http.StatusOK, http.StatusText(http.StatusOK), models.PageableResponse{}))
 	webservice.Route(webservice.GET("/projects/{project}/domains").
-		To(h.ListDomains).
+		To(h.ListDomainsProject).
 		Metadata(restfulspec.KeyOpenAPITags, []string{CoreClusterTag}).
+		Param(webservice.PathParameter("project", "project name")).
 		Doc("List domains api for project.").
 		Param(webservice.QueryParameter(query.PagingParam, "paging query, e.g. limit=100,page=1").
 			Required(false).
@@ -1104,8 +1105,9 @@ func SetupWebService(h *handler) *restful.WebService {
 		Returns(http.StatusOK, http.StatusText(http.StatusOK), models.PageableResponse{}).
 		Returns(http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError), errors.HTTPError{}))
 	webservice.Route(webservice.GET("/projects/{project}/templates").
-		To(h.ListTemplates).
+		To(h.ListTemplatesProject).
 		Metadata(restfulspec.KeyOpenAPITags, []string{CoreClusterTag}).
+		Param(webservice.PathParameter("project", "project name")).
 		Doc("List templates api for project.").
 		Param(webservice.QueryParameter(query.PagingParam, "paging query, e.g. limit=100,page=1").
 			Required(false).
@@ -1202,7 +1204,8 @@ func SetupWebService(h *handler) *restful.WebService {
 	webservice.Route(webservice.GET("/projects/{project}/backuppoints").
 		Doc("List of backup point api for project").
 		Metadata(restfulspec.KeyOpenAPITags, []string{CoreClusterTag}).
-		To(h.ListBackupPoints).
+		Param(webservice.PathParameter("project", "project name")).
+		To(h.ListBackupPointsProject).
 		Param(webservice.QueryParameter(query.PagingParam, "paging query, e.g. limit=100,page=1").
 			Required(false).
 			DataFormat("limit=%d,page=%d").
@@ -1541,8 +1544,9 @@ func SetupWebService(h *handler) *restful.WebService {
 			Required(false)).
 		Returns(http.StatusOK, http.StatusText(http.StatusOK), models.PageableResponse{}))
 	webservice.Route(webservice.GET("/projects/{project}/registries").
-		To(h.ListRegistry).
+		To(h.ListRegistryProject).
 		Metadata(restfulspec.KeyOpenAPITags, []string{CoreClusterTag}).
+		Param(webservice.PathParameter("project", "project name")).
 		Doc("List Registries api for projects.").
 		Param(webservice.QueryParameter(query.PagingParam, "paging query, e.g. limit=100,page=1").
 			Required(false).
diff --git a/pkg/models/cluster/cluster.go b/pkg/models/cluster/cluster.go
index 50c2c24..f99cebf 100644
--- a/pkg/models/cluster/cluster.go
+++ b/pkg/models/cluster/cluster.go
@@ -320,7 +320,7 @@ func (c *clusterOperator) ListBackupPoints(ctx context.Context, query *query.Que
 }
 
 func (c *clusterOperator) ListBackupPointEx(ctx context.Context, query *query.Query) (*models.PageableResponse, error) {
-	return models.ListExV2(ctx, c.backupPointStorage, query, c.backupPointFuzzyFilter, nil, nil)
+	return models.ListExV2(ctx, c.backupPointStorage, query, BackupPointFuzzyFilter, nil, nil)
 }
 
 func (c *clusterOperator) CreateBackupPoint(ctx context.Context, backupPoint *v1.BackupPoint) (*v1.BackupPoint, error) {
@@ -438,7 +438,7 @@ func (c *clusterOperator) WatchDomain(ctx context.Context, query *query.Query) (
 }
 
 func (c *clusterOperator) ListDomainsEx(ctx context.Context, query *query.Query) (*models.PageableResponse, error) {
-	return models.ListExV2(ctx, c.dnsStorage, query, c.dnsFuzzyFilter, nil, nil)
+	return models.ListExV2(ctx, c.dnsStorage, query, DNSFuzzyFilter, nil, nil)
 }
 
 func (c *clusterOperator) GetDomain(ctx context.Context, name string) (*v1.Domain, error) {
@@ -591,7 +591,7 @@ func RegionFuzzyFilter(obj runtime.Object, q *query.Query) []runtime.Object {
 	return objs
 }
 
-func (c *clusterOperator) dnsFuzzyFilter(obj runtime.Object, q *query.Query) []runtime.Object {
+func DNSFuzzyFilter(obj runtime.Object, q *query.Query) []runtime.Object {
 	domains, ok := obj.(*v1.DomainList)
 	if !ok {
 		return nil
@@ -631,7 +631,7 @@ func (c *clusterOperator) backupFuzzyFilter(obj runtime.Object, q *query.Query)
 	return objs
 }
 
-func (c *clusterOperator) backupPointFuzzyFilter(obj runtime.Object, q *query.Query) []runtime.Object {
+func BackupPointFuzzyFilter(obj runtime.Object, q *query.Query) []runtime.Object {
 	backupPoints, ok := obj.(*v1.BackupPointList)
 	if !ok {
 		return nil
@@ -702,7 +702,7 @@ func (c *clusterOperator) GetTemplate(ctx context.Context, name string) (*v1.Tem
 }
 
 func (c *clusterOperator) ListTemplatesEx(ctx context.Context, query *query.Query) (*models.PageableResponse, error) {
-	return models.ListExV2(ctx, c.templateStorage, query, c.templateFuzzyFilter, nil, nil)
+	return models.ListExV2(ctx, c.templateStorage, query, TemplateFuzzyFilter, nil, nil)
 }
 
 func (c *clusterOperator) GetTemplateEx(ctx context.Context, name string, resourceVersion string) (*v1.Template, error) {
@@ -752,7 +752,7 @@ func (c *clusterOperator) DeleteTemplateCollection(ctx context.Context, query *q
 	return nil
 }
 
-func (c *clusterOperator) templateFuzzyFilter(obj runtime.Object, q *query.Query) []runtime.Object {
+func TemplateFuzzyFilter(obj runtime.Object, q *query.Query) []runtime.Object {
 	templates, ok := obj.(*v1.TemplateList)
 	if !ok {
 		return nil
@@ -875,7 +875,7 @@ func (c *clusterOperator) GetRegistryEx(ctx context.Context, name string, resour
 }
 
 func (c *clusterOperator) ListRegistriesEx(ctx context.Context, query *query.Query) (*models.PageableResponse, error) {
-	return models.ListExV2(ctx, c.registryStorage, query, c.registryFuzzyFilter, nil, nil)
+	return models.ListExV2(ctx, c.registryStorage, query, RegistryFuzzyFilter, nil, nil)
 }
 
 func (c *clusterOperator) CreateRegistry(ctx context.Context, r *v1.Registry) (*v1.Registry, error) {
@@ -906,7 +906,7 @@ func (c *clusterOperator) DeleteRegistry(ctx context.Context, name string) error
 	return err
 }
 
-func (c *clusterOperator) registryFuzzyFilter(obj runtime.Object, q *query.Query) []runtime.Object {
+func RegistryFuzzyFilter(obj runtime.Object, q *query.Query) []runtime.Object {
 	registries, ok := obj.(*v1.RegistryList)
 	if !ok {
 		return nil
diff --git a/pkg/models/models.go b/pkg/models/models.go
index d75b5d3..163475c 100644
--- a/pkg/models/models.go
+++ b/pkg/models/models.go
@@ -169,6 +169,42 @@ func DefaultList(object runtime.Object, q *query.Query, litToSliceFunc ListToObj
 	}, nil
 }
 
+// DefaultListMerge a func to build a PageableResponse from many obj
+func DefaultListMerge(objects []runtime.Object, litToSliceFunc ListToObjectSliceFunction, compareFunc CompareFunc, mutatingFunc MutatingFunc) (*PageableResponse, error) {
+	if mutatingFunc == nil {
+		mutatingFunc = DefaultMutatingFunc
+	}
+	if compareFunc == nil {
+		compareFunc = DefaultCompareFunc
+	}
+	objs := make([]runtime.Object, 0)
+	q := query.New()
+	for _, object := range objects {
+		obj := litToSliceFunc(object.DeepCopyObject(), q)
+		objs = append(objs, obj...)
+	}
+	totalCount := len(objs)
+	// sort
+	sort.Slice(objs, func(i, j int) bool {
+		if q.Reverse {
+			return !compareFunc(objs[i], objs[j], q.OrderBy)
+		}
+		return compareFunc(objs[i], objs[j], q.OrderBy)
+	})
+	// limit offset
+	start, end := q.Pagination.GetValidPagination(totalCount)
+
+	objs = objs[start:end]
+	items := make([]interface{}, 0, len(objs))
+	for i := range objs {
+		items = append(items, mutatingFunc(objs[i]))
+	}
+	return &PageableResponse{
+		Items:      items,
+		TotalCount: totalCount,
+	}, nil
+}
+
 type ListToObjectSliceFunction func(runtime.Object, *query.Query) []runtime.Object
 type CompareFunc func(left runtime.Object, right runtime.Object, orderBy string) bool
 type FilterFunc func(obj runtime.Object) bool
diff --git a/pkg/query/query.go b/pkg/query/query.go
index cb72a56..ceaba17 100644
--- a/pkg/query/query.go
+++ b/pkg/query/query.go
@@ -152,12 +152,17 @@ func (q *Query) GetFieldSelector() fields.Selector {
 }
 
 // AddLabelSelector add labelSelector to query.
-func (q *Query) AddLabelSelector(selector []string) {
+func (q *Query) AddLabelSelector(selectors []string) {
 	if q.LabelSelector == "" {
-		q.LabelSelector = strings.Join(selector, ",")
+		q.LabelSelector = strings.Join(selectors, ",")
 		return
 	}
-	q.LabelSelector = fmt.Sprintf("%s,%s", q.LabelSelector, strings.Join(selector, ","))
+	for _, selector := range selectors {
+		if q.HasLabelSelector(selector) {
+			continue
+		}
+		q.LabelSelector = fmt.Sprintf("%s,%s", q.LabelSelector, selector)
+	}
 }
 
 // HasLabelSelector check label is exist
@@ -171,6 +176,26 @@ func (q *Query) HasLabelSelector(selector string) bool {
 	return false
 }
 
+// DeepCopy copy query.
+func (q *Query) DeepCopy() *Query {
+	out := *q
+	if q.Pagination != nil {
+		out.Pagination = newPagination(q.Pagination.Limit, q.Pagination.Offset)
+	}
+	if q.TimeoutSeconds != nil {
+		newTimeoutSeconds := *q.TimeoutSeconds
+		out.TimeoutSeconds = &newTimeoutSeconds
+	}
+	if q.FuzzySearch != nil {
+		newFuzzySearch := make(map[string]string)
+		for k, v := range q.FuzzySearch {
+			newFuzzySearch[k] = v
+		}
+		out.FuzzySearch = q.FuzzySearch
+	}
+	return &out
+}
+
 func New() *Query {
 	return &Query{
 		Pagination: NoPagination(),
diff --git a/pkg/server/restplus/multi_watch.go b/pkg/server/restplus/multi_watch.go
new file mode 100644
index 0000000..e6e1d46
--- /dev/null
+++ b/pkg/server/restplus/multi_watch.go
@@ -0,0 +1,66 @@
+package restplus
+
+import (
+	"sync"
+
+	"k8s.io/apimachinery/pkg/watch"
+)
+
+type multiWatcher struct {
+	watchers []watch.Interface
+	stop     chan struct{}
+
+	ch      chan watch.Event
+	stopped bool
+	sync.Mutex
+}
+
+func NewMultiWatcher(watchers []watch.Interface) watch.Interface {
+	m := &multiWatcher{
+		watchers: watchers,
+		ch:       make(chan watch.Event),
+		stop:     make(chan struct{}),
+	}
+
+	// merge watchers
+	var wg sync.WaitGroup
+	wg.Add(len(m.watchers))
+
+	for _, watcher := range m.watchers {
+		go func(w watch.Interface) {
+			defer wg.Done()
+			events := w.ResultChan()
+			for event := range events {
+				m.ch <- event
+			}
+		}(watcher)
+	}
+	go func() {
+		wg.Wait()
+		// when all watcher closed,send a signal to stop chan
+		m.stop <- struct{}{}
+	}()
+	return m
+}
+
+func (m *multiWatcher) Stop() {
+	m.Lock()
+	defer m.Unlock()
+
+	if m.stopped {
+		return
+	}
+
+	m.stopped = true
+	for _, watcher := range m.watchers {
+		watcher.Stop()
+	}
+
+	// waiting for all watcher closed
+	<-m.stop
+	close(m.ch)
+}
+
+func (m *multiWatcher) ResultChan() <-chan watch.Event {
+	return m.ch
+}


Test patch:
diff --git a/pkg/query/query_test.go b/pkg/query/query_test.go
index 0cb648b..6c854d0 100644
--- a/pkg/query/query_test.go
+++ b/pkg/query/query_test.go
@@ -49,3 +49,31 @@ func Test_parseFuzzy(t *testing.T) {
 		})
 	}
 }
+
+func TestQuery_AddLabelSelector(t *testing.T) {
+	type args struct {
+		selectors []string
+	}
+	tests := []struct {
+		name string
+		old  args
+		add  args
+		want string
+	}{
+		{name: "empty", old: args{selectors: []string{""}}, add: args{selectors: []string{""}}, want: ""},
+		{name: "normal", old: args{selectors: []string{""}}, add: args{selectors: []string{"a=b"}}, want: "a=b"},
+		{name: "normal2", old: args{selectors: []string{"a=b"}}, add: args{selectors: []string{"c=d"}}, want: "a=b,c=d"},
+		{name: "normal3", old: args{selectors: []string{"a=b", "c=d"}}, add: args{selectors: []string{"e=f"}}, want: "a=b,c=d,e=f"},
+		{name: "override", old: args{selectors: []string{"a=b", "c=d"}}, add: args{selectors: []string{"c=d"}}, want: "a=b,c=d"},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			q := New()
+			q.AddLabelSelector(tt.old.selectors)
+			q.AddLabelSelector(tt.add.selectors)
+			if q.LabelSelector != tt.want {
+				t.Fatalf("want:%s got:%s", tt.want, q.LabelSelector)
+			}
+		})
+	}
+}
diff --git a/pkg/server/restplus/multi_watch_test.go b/pkg/server/restplus/multi_watch_test.go
new file mode 100644
index 0000000..d67dea7
--- /dev/null
+++ b/pkg/server/restplus/multi_watch_test.go
@@ -0,0 +1,48 @@
+package restplus
+
+import (
+	"strconv"
+	"sync"
+	"testing"
+
+	"k8s.io/apimachinery/pkg/runtime"
+	"k8s.io/apimachinery/pkg/runtime/schema"
+	"k8s.io/apimachinery/pkg/watch"
+)
+
+type testType string
+
+func (obj testType) GetObjectKind() schema.ObjectKind { return schema.EmptyObjectKind }
+func (obj testType) DeepCopyObject() runtime.Object   { return obj }
+
+func TestNewMultiWatcher(t *testing.T) {
+	w1 := watch.NewFake()
+	w2 := watch.NewFake()
+	m := NewMultiWatcher([]watch.Interface{w1, w2})
+
+	var wg sync.WaitGroup
+	wg.Add(2)
+	go func() {
+		defer wg.Done()
+		for i := 0; i < 10; i++ {
+			w1.Add(testType("watcher1" + strconv.Itoa(i)))
+		}
+	}()
+
+	go func() {
+		defer wg.Done()
+		for i := 0; i < 10; i++ {
+			w2.Add(testType("watcher2" + strconv.Itoa(i)))
+		}
+	}()
+
+	go func() {
+		wg.Wait()
+		m.Stop()
+	}()
+
+	resultChan := m.ResultChan()
+	for event := range resultChan {
+		t.Log("event:", event)
+	}
+}