You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@dubbo.apache.org by gi...@apache.org on 2026/07/22 15:22:46 UTC

(dubbo-kubernetes) branch master updated: Provide support for virtual machines (#978)

This is an automated email from the ASF dual-hosted git repository.

github-actions[bot] pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/dubbo-kubernetes.git


The following commit(s) were added to refs/heads/master by this push:
     new 0c953c40 Provide support for virtual machines (#978)
0c953c40 is described below

commit 0c953c407100c9790bb439ba822d3c82e1983997
Author: mfordjody <11...@qq.com>
AuthorDate: Wed Jul 22 23:22:40 2026 +0800

    Provide support for virtual machines (#978)
    
    * Added probe injection feature
    
    * Fix release workflow action policy (#977)
    
    * Provide support for virtual machines
    
    * fix ci
---
 README.md                                          | 19 ++---
 .../tests/PERFORMANCE.md                           |  0
 dubbod/discovery/pkg/config/kube/crd/conversion.go | 49 ++++++-----
 .../pkg/config/kube/crd/conversion_parse_test.go   | 76 +++++++++++++++++
 .../pkg/config/kube/crd/conversion_test.go         | 43 ++++++++++
 dubbod/discovery/pkg/model/service.go              |  9 ++-
 .../pkg/serviceregistry/serviceentry/controller.go | 39 +++++++--
 .../serviceentry/controller_test.go                | 18 ++++-
 dubbod/discovery/pkg/xds/debug.go                  | 83 +++++++++++++++++++
 dubbod/discovery/pkg/xds/debug_test.go             | 94 ++++++++++++++++++++++
 .../pkg/xds/endpoints/endpoint_builder.go          | 74 ++++++++++++++---
 .../pkg/xds/endpoints/endpoint_builder_test.go     | 49 +++++++++++
 go.mod                                             |  2 +-
 go.sum                                             |  4 +-
 manifests/profiles/observability.yaml              | 39 ---------
 pkg/config/validation/validators.go                | 26 +++++-
 pkg/config/validation/validators_test.go           |  6 +-
 samples/vm/README.md                               | 38 +++++++++
 samples/vm/vm-service.yaml                         | 39 +++++++++
 tests/e2e/run.sh                                   | 43 ++++++++--
 tests/e2e/testdata/vm-workload.yaml                | 36 +++++++++
 21 files changed, 687 insertions(+), 99 deletions(-)

diff --git a/README.md b/README.md
index f75b9413..a02356ea 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,4 @@
-# Apache Kdubbo (dubbo-mesh)
+# Apache Kdubbo - dubbo-mesh
 
 [![Go Reference](https://pkg.go.dev/badge/github.com/apache/dubbo-kubernetes.svg)](https://pkg.go.dev/github.com/apache/dubbo-kubernetes)
 [![Go Report Card](https://goreportcard.com/badge/github.com/apache/dubbo-kubernetes)](https://goreportcard.com/report/github.com/apache/dubbo-kubernetes)
@@ -22,27 +22,22 @@ Dubbo component composition:
 
 Projects are distributed across the code directory repositories:
 
-- [dubbo/api](https://github.com/kdubbo/api). — Defines the component level APIs for the Dubbo control plane.
+- [api](https://github.com/kdubbo/api). — Defines the component level APIs for the Dubbo control plane.
 
-- [dubbo/xds-api](https://github.com/kdubbo/xds-api). — Define the xDS API for the Dubbo control plane.
+- [xds-api](https://github.com/kdubbo/xds-api). — Define the xDS API for the Dubbo control plane.
 
-- [dubbo/client-go](https://github.com/kdubbo/client-go). — Defines the Kubernetes clients automatically generated for Dubbo control plane resources.
+- [client-go](https://github.com/kdubbo/client-go). — Defines the Kubernetes clients automatically generated for Dubbo control plane resources.
 
-- [dubbo/cli](./cli). — Provides dubboctl command line tools for control plane management and other operations.
+- [dubboctl](./cli). — Provides dubboctl command line tools for control plane management and other operations.
 
-- [dubbo/dubbod](./dubbod) — The main code directory for the Dubbo control plane.
+- [dubbod](./dubbod) — The main code directory for the Dubbo control plane.
 
-- [dubbo/operator](./operator). — Provides user friendly options for operating the service mesh.
+- [operator](./operator). — Provides user friendly options for operating the service mesh.
 
 ## Contributing
 
 Refer to [CONTRIBUTING.md](./CONTRIBUTING.md)
 
-## Performance
-
-See [PERFORMANCE.md](./PERFORMANCE.md) for the control-plane scale matrix,
-repeatable benchmark commands, CI evidence, and cluster load-test methodology.
-
 ## License
 
 Apache License 2.0, see [LICENSE](https://github.com/apache/dubbo-kubernetes/blob/master/LICENSE).
diff --git a/PERFORMANCE.md b/architecture/tests/PERFORMANCE.md
similarity index 100%
rename from PERFORMANCE.md
rename to architecture/tests/PERFORMANCE.md
diff --git a/dubbod/discovery/pkg/config/kube/crd/conversion.go b/dubbod/discovery/pkg/config/kube/crd/conversion.go
index 749b5f90..a88c529d 100644
--- a/dubbod/discovery/pkg/config/kube/crd/conversion.go
+++ b/dubbod/discovery/pkg/config/kube/crd/conversion.go
@@ -24,7 +24,10 @@ import (
 	"reflect"
 
 	"github.com/apache/dubbo-kubernetes/pkg/config"
+	"github.com/apache/dubbo-kubernetes/pkg/config/schema/collections"
 	"github.com/apache/dubbo-kubernetes/pkg/config/schema/resource"
+	"google.golang.org/protobuf/encoding/protojson"
+	"google.golang.org/protobuf/proto"
 
 	kubeyaml "k8s.io/apimachinery/pkg/util/yaml"
 
@@ -56,26 +59,26 @@ func parseInputsImpl(inputs string, withValidate bool) ([]config.Config, []Dubbo
 			continue
 		}
 
-		// gvk := obj.GroupVersionKind()
-		// s, exists := collections.DubboGatewayAPI().FindByGroupVersionAliasesKind(resource.FromKubernetesGVK(&gvk))
-		// if !exists {
-		// 	log.Debugf("unrecognized type %v", obj.Kind)
-		// 	others = append(others, obj)
-		// 	continue
-		// }
-		//
-		// cfg, err := ConvertObject(s, &obj, "")
-		// if err != nil {
-		// 	return nil, nil, fmt.Errorf("cannot parse proto message for %v: %v", obj.Name, err)
-		// }
-		//
-		// if withValidate {
-		// 	if _, err := s.ValidateConfig(*cfg); err != nil {
-		// 		return nil, nil, fmt.Errorf("configuration is invalid: %v", err)
-		// 	}
-		// }
-		//
-		// varr = append(varr, *cfg)
+		gvk := obj.GroupVersionKind()
+		s, exists := collections.DubboGatewayAPI().FindByGroupVersionAliasesKind(resource.FromKubernetesGVK(&gvk))
+		if !exists {
+			log.Debugf("unrecognized type %v", obj.Kind)
+			others = append(others, obj)
+			continue
+		}
+
+		cfg, err := ConvertObject(s, &obj, "")
+		if err != nil {
+			return nil, nil, fmt.Errorf("cannot parse proto message for %v: %v", obj.Name, err)
+		}
+
+		if withValidate {
+			if _, err := s.ValidateConfig(*cfg); err != nil {
+				return nil, nil, fmt.Errorf("configuration is invalid: %v", err)
+			}
+		}
+
+		varr = append(varr, *cfg)
 	}
 
 	return varr, others, nil
@@ -90,6 +93,12 @@ func FromJSON(s resource.Schema, js string) (config.Spec, error) {
 	if err != nil {
 		return nil, err
 	}
+	if pb, ok := c.(proto.Message); ok {
+		if err = (protojson.UnmarshalOptions{DiscardUnknown: true}).Unmarshal([]byte(js), pb); err != nil {
+			return nil, err
+		}
+		return c, nil
+	}
 	if err = config.ApplyJSON(c, js); err != nil {
 		return nil, err
 	}
diff --git a/dubbod/discovery/pkg/config/kube/crd/conversion_parse_test.go b/dubbod/discovery/pkg/config/kube/crd/conversion_parse_test.go
new file mode 100644
index 00000000..c4d3b610
--- /dev/null
+++ b/dubbod/discovery/pkg/config/kube/crd/conversion_parse_test.go
@@ -0,0 +1,76 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements.  See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You 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 crd
+
+import (
+	"testing"
+
+	"github.com/apache/dubbo-kubernetes/pkg/config/schema/gvk"
+	networking "github.com/kdubbo/api/networking/v1alpha3"
+)
+
+func TestParseInputsServiceEntryAndWorkloadEntry(t *testing.T) {
+	configs, others, err := ParseInputs(`
+apiVersion: networking.dubbo.apache.org/v1alpha3
+kind: ServiceEntry
+metadata:
+  name: payment
+  namespace: default
+spec:
+  hosts: [payment.mesh.test]
+  addresses: [240.0.0.20]
+  ports:
+  - name: http
+    number: 8080
+    protocol: HTTP
+  resolution: STATIC
+  workloadSelector:
+    matchLabels:
+      app: payment
+---
+apiVersion: networking.dubbo.apache.org/v1alpha3
+kind: WorkloadEntry
+metadata:
+  name: payment-local
+  namespace: default
+spec:
+  address: 127.0.0.1
+  ports:
+    http: 18080
+  labels:
+    app: payment
+`)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(others) != 0 || len(configs) != 2 {
+		t.Fatalf("configs=%d others=%d, want configs=2 others=0", len(configs), len(others))
+	}
+	if configs[0].GroupVersionKind != gvk.ServiceEntry {
+		t.Fatalf("first kind = %v, want ServiceEntry", configs[0].GroupVersionKind)
+	}
+	service := configs[0].Spec.(*networking.ServiceEntry)
+	if service.GetResolution() != networking.ServiceEntry_STATIC || service.GetPorts()[0].GetProtocol() != "HTTP" {
+		t.Fatalf("unexpected ServiceEntry: %#v", service)
+	}
+	if configs[1].GroupVersionKind != gvk.WorkloadEntry {
+		t.Fatalf("second kind = %v, want WorkloadEntry", configs[1].GroupVersionKind)
+	}
+	workload := configs[1].Spec.(*networking.WorkloadEntry)
+	if workload.GetAddress() != "127.0.0.1" || workload.GetPorts()["http"] != 18080 {
+		t.Fatalf("unexpected WorkloadEntry: %#v", workload)
+	}
+}
diff --git a/dubbod/discovery/pkg/config/kube/crd/conversion_test.go b/dubbod/discovery/pkg/config/kube/crd/conversion_test.go
new file mode 100644
index 00000000..fe45627a
--- /dev/null
+++ b/dubbod/discovery/pkg/config/kube/crd/conversion_test.go
@@ -0,0 +1,43 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements.  See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You 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 crd
+
+import (
+	"testing"
+
+	"github.com/apache/dubbo-kubernetes/pkg/config/schema/collections"
+	telemetry "github.com/kdubbo/api/telemetry/v1alpha1"
+)
+
+func TestFromJSONTelemetryScalarWrappers(t *testing.T) {
+	spec, err := FromJSON(collections.Telemetry, `{
+		"tracing": [{
+			"randomSamplingPercentage": 100,
+			"disableSpanReporting": true
+		}]
+	}`)
+	if err != nil {
+		t.Fatalf("FromJSON() error = %v", err)
+	}
+
+	got := spec.(*telemetry.Telemetry).GetTracing()[0]
+	if got.GetRandomSamplingPercentage().GetValue() != 100 {
+		t.Fatalf("randomSamplingPercentage = %v, want 100", got.GetRandomSamplingPercentage().GetValue())
+	}
+	if !got.GetDisableSpanReporting().GetValue() {
+		t.Fatal("disableSpanReporting = false, want true")
+	}
+}
diff --git a/dubbod/discovery/pkg/model/service.go b/dubbod/discovery/pkg/model/service.go
index 2914ccfe..33429f89 100644
--- a/dubbod/discovery/pkg/model/service.go
+++ b/dubbod/discovery/pkg/model/service.go
@@ -159,6 +159,9 @@ type DubboEndpoint struct {
 	Addresses              []string
 	ServicePortName        string
 	Labels                 labels.Instance
+	Network                string
+	Locality               string
+	LbWeight               uint32
 	HealthStatus           HealthStatus
 	SendUnhealthyEndpoints bool
 	DiscoverabilityPolicy  EndpointDiscoverabilityPolicy `json:"-"`
@@ -195,7 +198,11 @@ func (ep *DubboEndpoint) Equals(other *DubboEndpoint) bool {
 	// Check things we can directly compare...
 	eq := ep.ServicePortName == other.ServicePortName &&
 		ep.ServiceAccount == other.ServiceAccount &&
-		ep.WorkloadName == other.WorkloadName
+		ep.WorkloadName == other.WorkloadName &&
+		ep.Network == other.Network &&
+		ep.Locality == other.Locality &&
+		ep.LbWeight == other.LbWeight &&
+		ep.SendUnhealthyEndpoints == other.SendUnhealthyEndpoints
 	if !eq {
 		return false
 	}
diff --git a/dubbod/discovery/pkg/serviceregistry/serviceentry/controller.go b/dubbod/discovery/pkg/serviceregistry/serviceentry/controller.go
index cd454339..2011940c 100644
--- a/dubbod/discovery/pkg/serviceregistry/serviceentry/controller.go
+++ b/dubbod/discovery/pkg/serviceregistry/serviceentry/controller.go
@@ -19,6 +19,7 @@ package serviceentry
 import (
 	"sort"
 	"strconv"
+	"strings"
 	"sync"
 
 	"github.com/apache/dubbo-kubernetes/dubbod/discovery/pkg/model"
@@ -33,6 +34,7 @@ import (
 	"github.com/apache/dubbo-kubernetes/pkg/config/visibility"
 	"github.com/apache/dubbo-kubernetes/pkg/slices"
 	"github.com/apache/dubbo-kubernetes/pkg/util/sets"
+	meta "github.com/kdubbo/api/meta/v1alpha1"
 	networking "github.com/kdubbo/api/networking/v1alpha3"
 )
 
@@ -204,7 +206,7 @@ func selectWorkloads(namespace string, entry *networking.ServiceEntry, configs [
 	if selector == nil {
 		out := make([]namedWorkload, 0, len(entry.GetEndpoints()))
 		for i, workload := range entry.GetEndpoints() {
-			out = append(out, namedWorkload{name: entryName(i), workload: workload})
+			out = append(out, namedWorkload{name: entryName(i), workload: workload, healthStatus: model.Healthy})
 		}
 		return out
 	}
@@ -216,7 +218,7 @@ func selectWorkloads(namespace string, entry *networking.ServiceEntry, configs [
 		}
 		workload, ok := cfg.Spec.(*networking.WorkloadEntry)
 		if ok && wanted.Match(labels.Instance(workload.GetLabels())) {
-			out = append(out, namedWorkload{name: cfg.Name, workload: workload})
+			out = append(out, namedWorkload{name: cfg.Name, workload: workload, healthStatus: workloadHealth(cfg.Status)})
 		}
 	}
 	sort.Slice(out, func(i, j int) bool { return out[i].name < out[j].name })
@@ -224,8 +226,26 @@ func selectWorkloads(namespace string, entry *networking.ServiceEntry, configs [
 }
 
 type namedWorkload struct {
-	name     string
-	workload *networking.WorkloadEntry
+	name         string
+	workload     *networking.WorkloadEntry
+	healthStatus model.HealthStatus
+}
+
+func workloadHealth(status config.Status) model.HealthStatus {
+	dubboStatus, ok := status.(*meta.DubboStatus)
+	if !ok || dubboStatus == nil {
+		return model.Healthy
+	}
+	for _, condition := range dubboStatus.GetConditions() {
+		if condition == nil || (!strings.EqualFold(condition.GetType(), "Ready") && !strings.EqualFold(condition.GetType(), "Healthy")) {
+			continue
+		}
+		if strings.EqualFold(condition.GetStatus(), "True") {
+			return model.Healthy
+		}
+		return model.UnHealthy
+	}
+	return model.Healthy
 }
 
 func entryName(index int) string {
@@ -234,7 +254,7 @@ func entryName(index int) string {
 
 func buildEndpoints(cfg config.Config, entry *networking.ServiceEntry, svc *model.Service, workloads []namedWorkload) []*model.DubboEndpoint {
 	if len(workloads) == 0 && (entry.GetResolution() == networking.ServiceEntry_DNS || entry.GetResolution() == networking.ServiceEntry_DNS_ROUND_ROBIN) {
-		workloads = []namedWorkload{{name: cfg.Name, workload: &networking.WorkloadEntry{Address: string(svc.Hostname)}}}
+		workloads = []namedWorkload{{name: cfg.Name, workload: &networking.WorkloadEntry{Address: string(svc.Hostname)}, healthStatus: model.Healthy}}
 	}
 	result := make([]*model.DubboEndpoint, 0, len(workloads)*len(entry.GetPorts()))
 	for _, named := range workloads {
@@ -246,12 +266,19 @@ func buildEndpoints(cfg config.Config, entry *networking.ServiceEntry, svc *mode
 			if targetPort == 0 {
 				targetPort = port.GetNumber()
 			}
+			weight := named.workload.GetWeight()
+			if weight == 0 {
+				weight = 1
+			}
 			result = append(result, &model.DubboEndpoint{
 				ServiceAccount:  named.workload.GetServiceAccount(),
 				Addresses:       []string{named.workload.GetAddress()},
 				ServicePortName: port.GetName(),
 				Labels:          labels.Instance(named.workload.GetLabels()),
-				HealthStatus:    model.Healthy,
+				Network:         named.workload.GetNetwork(),
+				Locality:        named.workload.GetLocality(),
+				LbWeight:        weight,
+				HealthStatus:    named.healthStatus,
 				EndpointPort:    targetPort,
 				WorkloadName:    named.name,
 				Namespace:       cfg.Namespace,
diff --git a/dubbod/discovery/pkg/serviceregistry/serviceentry/controller_test.go b/dubbod/discovery/pkg/serviceregistry/serviceentry/controller_test.go
index b513c459..59f92ab6 100644
--- a/dubbod/discovery/pkg/serviceregistry/serviceentry/controller_test.go
+++ b/dubbod/discovery/pkg/serviceregistry/serviceentry/controller_test.go
@@ -25,6 +25,7 @@ import (
 	"github.com/apache/dubbo-kubernetes/pkg/config"
 	"github.com/apache/dubbo-kubernetes/pkg/config/schema/collections"
 	"github.com/apache/dubbo-kubernetes/pkg/config/schema/gvk"
+	meta "github.com/kdubbo/api/meta/v1alpha1"
 	networking "github.com/kdubbo/api/networking/v1alpha3"
 	typev1alpha3 "github.com/kdubbo/api/type/v1alpha3"
 )
@@ -46,8 +47,11 @@ func TestControllerReconcilesServiceEntryAndWorkloadEntry(t *testing.T) {
 		Address:        "10.0.0.10",
 		Ports:          map[string]uint32{"grpc": 16000},
 		Labels:         map[string]string{"app": "reviews"},
+		Network:        "vm-network",
+		Locality:       "us-east-1/zone-a/rack-1",
+		Weight:         7,
 		ServiceAccount: "reviews",
-	}}
+	}, Status: &meta.DubboStatus{Conditions: []*meta.DubboCondition{{Type: "Ready", Status: "False"}}}}
 	configs.create(t, workload)
 	entry := config.Config{Meta: config.Meta{
 		GroupVersionKind: gvk.ServiceEntry,
@@ -80,6 +84,18 @@ func TestControllerReconcilesServiceEntryAndWorkloadEntry(t *testing.T) {
 	if endpoint.FirstAddressOrNil() != "10.0.0.10" || endpoint.EndpointPort != 16000 || endpoint.ServiceAccount != "reviews" {
 		t.Fatalf("unexpected endpoint: address=%s port=%d serviceAccount=%s", endpoint.FirstAddressOrNil(), endpoint.EndpointPort, endpoint.ServiceAccount)
 	}
+	if endpoint.Network != "vm-network" || endpoint.Locality != "us-east-1/zone-a/rack-1" || endpoint.LbWeight != 7 {
+		t.Fatalf("unexpected topology: network=%s locality=%s weight=%d", endpoint.Network, endpoint.Locality, endpoint.LbWeight)
+	}
+	if endpoint.HealthStatus != model.UnHealthy {
+		t.Fatalf("endpoint health = %v, want unhealthy", endpoint.HealthStatus)
+	}
+
+	workload.Status.(*meta.DubboStatus).Conditions[0].Status = "True"
+	configs.update(t, workload)
+	if got := updater.lastEDS(t).endpoints[0].HealthStatus; got != model.Healthy {
+		t.Fatalf("updated endpoint health = %v, want healthy", got)
+	}
 
 	workload.Spec.(*networking.WorkloadEntry).Address = "10.0.0.11"
 	configs.update(t, workload)
diff --git a/dubbod/discovery/pkg/xds/debug.go b/dubbod/discovery/pkg/xds/debug.go
index 92fdd77f..d8ede7e3 100644
--- a/dubbod/discovery/pkg/xds/debug.go
+++ b/dubbod/discovery/pkg/xds/debug.go
@@ -23,6 +23,7 @@ import (
 	"sort"
 	"time"
 
+	"github.com/apache/dubbo-kubernetes/dubbod/discovery/pkg/model"
 	"github.com/apache/dubbo-kubernetes/pkg/config/schema/collections"
 )
 
@@ -64,6 +65,7 @@ func (s *DiscoveryServer) AppendDebugHandlers(mux *http.ServeMux) {
 	register("/debug/syncz", "Synchronization status of all proxies connected to this dubbod instance", s.syncz)
 	register("/debug/configz", "Configuration resources known to this dubbod instance", s.configz)
 	register("/debug/registryz", "Services in the service registry", s.registryz)
+	register("/debug/endpointz", "Endpoints published through xDS", s.endpointz)
 
 	mux.HandleFunc("/debug", func(w http.ResponseWriter, req *http.Request) {
 		w.Header().Set("Content-Type", "application/json")
@@ -158,6 +160,87 @@ func (s *DiscoveryServer) registryz(w http.ResponseWriter, _ *http.Request) {
 	writeJSON(w, services)
 }
 
+func (s *DiscoveryServer) endpointz(w http.ResponseWriter, _ *http.Request) {
+	w.Header().Set("Content-Type", "application/json")
+	if s.Env == nil || s.Env.EndpointIndex == nil {
+		writeJSON(w, []any{})
+		return
+	}
+	type debugEndpoint struct {
+		Hostname       string `json:"hostname"`
+		Namespace      string `json:"namespace"`
+		Address        string `json:"address"`
+		Port           uint32 `json:"port"`
+		Health         string `json:"health"`
+		Network        string `json:"network,omitempty"`
+		Locality       string `json:"locality,omitempty"`
+		Weight         uint32 `json:"weight"`
+		ServiceAccount string `json:"serviceAccount,omitempty"`
+		Workload       string `json:"workload,omitempty"`
+	}
+	result := make([]debugEndpoint, 0)
+	push := s.Env.PushContext()
+	if push == nil {
+		writeJSON(w, result)
+		return
+	}
+	for _, svc := range push.GetAllServices() {
+		if svc == nil {
+			continue
+		}
+		shards, found := s.Env.EndpointIndex.ShardsForService(string(svc.Hostname), svc.Attributes.Namespace)
+		if !found {
+			continue
+		}
+		shards.RLock()
+		for _, endpoints := range shards.Shards {
+			for _, ep := range endpoints {
+				if ep == nil {
+					continue
+				}
+				result = append(result, debugEndpoint{
+					Hostname:       string(svc.Hostname),
+					Namespace:      svc.Attributes.Namespace,
+					Address:        ep.FirstAddressOrNil(),
+					Port:           ep.EndpointPort,
+					Health:         endpointHealthString(ep.HealthStatus),
+					Network:        ep.Network,
+					Locality:       ep.Locality,
+					Weight:         ep.LbWeight,
+					ServiceAccount: ep.ServiceAccount,
+					Workload:       ep.WorkloadName,
+				})
+			}
+		}
+		shards.RUnlock()
+	}
+	sort.Slice(result, func(i, j int) bool {
+		if result[i].Hostname != result[j].Hostname {
+			return result[i].Hostname < result[j].Hostname
+		}
+		if result[i].Address != result[j].Address {
+			return result[i].Address < result[j].Address
+		}
+		return result[i].Port < result[j].Port
+	})
+	writeJSON(w, result)
+}
+
+func endpointHealthString(status model.HealthStatus) string {
+	switch status {
+	case model.Healthy:
+		return "HEALTHY"
+	case model.UnHealthy:
+		return "UNHEALTHY"
+	case model.Draining:
+		return "DRAINING"
+	case model.Terminating:
+		return "TERMINATING"
+	default:
+		return "UNKNOWN"
+	}
+}
+
 func writeJSON(w http.ResponseWriter, obj any) {
 	enc := json.NewEncoder(w)
 	enc.SetIndent("", "  ")
diff --git a/dubbod/discovery/pkg/xds/debug_test.go b/dubbod/discovery/pkg/xds/debug_test.go
new file mode 100644
index 00000000..e9244074
--- /dev/null
+++ b/dubbod/discovery/pkg/xds/debug_test.go
@@ -0,0 +1,94 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements.  See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You 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 xds
+
+import (
+	"encoding/json"
+	"net/http/httptest"
+	"testing"
+
+	"github.com/apache/dubbo-kubernetes/dubbod/discovery/pkg/model"
+	"github.com/apache/dubbo-kubernetes/pkg/config/host"
+	"github.com/apache/dubbo-kubernetes/pkg/config/mesh"
+	"github.com/apache/dubbo-kubernetes/pkg/config/mesh/meshwatcher"
+	"github.com/apache/dubbo-kubernetes/pkg/config/protocol"
+	"github.com/apache/dubbo-kubernetes/pkg/kube/krt"
+)
+
+func TestEndpointzReportsVMEndpointState(t *testing.T) {
+	hostname := host.Name("reviews-vm.mesh.local")
+	service := &model.Service{
+		Hostname: hostname,
+		Ports: model.PortList{{
+			Name: "grpc", Port: 50051, Protocol: protocol.GRPC,
+		}},
+		Attributes: model.ServiceAttributes{Name: "reviews-vm", Namespace: "bookinfo"},
+	}
+	env := model.NewEnvironment()
+	env.ServiceDiscovery = debugServiceDiscovery{service: service}
+	env.ConfigStore = testConfigStore{}
+	env.Watcher = meshwatcher.ConfigAdapter(krt.NewStatic(&meshwatcher.MeshConfigResource{
+		MeshConfig: mesh.DefaultMeshConfig(),
+	}, true))
+	env.Init()
+	push := model.NewPushContext()
+	push.InitContext(env, nil, nil)
+	env.SetPushContext(push)
+	env.EndpointIndex.UpdateServiceEndpoints(model.ShardKey{}, string(hostname), "bookinfo", []*model.DubboEndpoint{{
+		Addresses:       []string{"192.0.2.10"},
+		EndpointPort:    50051,
+		ServicePortName: "grpc",
+		HealthStatus:    model.UnHealthy,
+		Network:         "vm-network",
+		Locality:        "us-east-1/zone-a/rack-1",
+		LbWeight:        7,
+		ServiceAccount:  "reviews-vm",
+		WorkloadName:    "reviews-vm",
+	}}, false)
+
+	recorder := httptest.NewRecorder()
+	NewDiscoveryServer(env, nil, nil).endpointz(recorder, httptest.NewRequest("GET", "/debug/endpointz", nil))
+	if recorder.Code != 200 {
+		t.Fatalf("status code = %d, want 200", recorder.Code)
+	}
+	var result []map[string]any
+	if err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil {
+		t.Fatalf("decode endpointz: %v", err)
+	}
+	if len(result) != 1 {
+		t.Fatalf("endpoint count = %d, want 1", len(result))
+	}
+	endpoint := result[0]
+	if endpoint["hostname"] != string(hostname) || endpoint["address"] != "192.0.2.10" || endpoint["health"] != "UNHEALTHY" {
+		t.Fatalf("unexpected endpoint: %v", endpoint)
+	}
+	if endpoint["network"] != "vm-network" || endpoint["locality"] != "us-east-1/zone-a/rack-1" || endpoint["weight"] != float64(7) {
+		t.Fatalf("unexpected endpoint topology: %v", endpoint)
+	}
+}
+
+type debugServiceDiscovery struct {
+	service *model.Service
+}
+
+func (d debugServiceDiscovery) Services() []*model.Service { return []*model.Service{d.service} }
+func (d debugServiceDiscovery) GetService(name host.Name) *model.Service {
+	if d.service != nil && d.service.Hostname == name {
+		return d.service
+	}
+	return nil
+}
+func (debugServiceDiscovery) GetProxyServiceTargets(*model.Proxy) []model.ServiceTarget { return nil }
diff --git a/dubbod/discovery/pkg/xds/endpoints/endpoint_builder.go b/dubbod/discovery/pkg/xds/endpoints/endpoint_builder.go
index 97b41a28..3764d603 100644
--- a/dubbod/discovery/pkg/xds/endpoints/endpoint_builder.go
+++ b/dubbod/discovery/pkg/xds/endpoints/endpoint_builder.go
@@ -18,6 +18,8 @@ package endpoints
 
 import (
 	"fmt"
+	"sort"
+	"strings"
 
 	"github.com/apache/dubbo-kubernetes/dubbod/discovery/pkg/features"
 	"github.com/apache/dubbo-kubernetes/dubbod/discovery/pkg/model"
@@ -35,6 +37,7 @@ import (
 	// endpoint "github.com/kdubbo/xds-api/endpoint/v1"
 	endpoint "github.com/kdubbo/xds-api/endpoint/v1"
 
+	"google.golang.org/protobuf/types/known/structpb"
 	"google.golang.org/protobuf/types/known/wrapperspb"
 )
 
@@ -137,6 +140,12 @@ func (b *EndpointBuilder) BuildClusterLoadAssignmentWithGateways(endpointIndex *
 	defer shards.RUnlock()
 
 	// Filter and convert endpoints
+	type localityEndpoints struct {
+		locality  *core.Locality
+		endpoints []*endpoint.LbEndpoint
+		weight    uint32
+	}
+	localities := make(map[string]*localityEndpoints)
 	var lbEndpoints []*endpoint.LbEndpoint
 	var filteredCount int
 	var totalEndpoints int
@@ -193,6 +202,13 @@ func (b *EndpointBuilder) BuildClusterLoadAssignmentWithGateways(endpointIndex *
 				continue
 			}
 			lbEndpoints = append(lbEndpoints, lbEp)
+			group := localities[ep.Locality]
+			if group == nil {
+				group = &localityEndpoints{locality: parseLocality(ep.Locality)}
+				localities[ep.Locality] = group
+			}
+			group.endpoints = append(group.endpoints, lbEp)
+			group.weight += lbEp.GetLoadBalancingWeight().GetValue()
 		}
 	}
 
@@ -208,7 +224,6 @@ func (b *EndpointBuilder) BuildClusterLoadAssignmentWithGateways(endpointIndex *
 		return buildEmptyClusterLoadAssignment(b.clusterName)
 	}
 
-	// Create LocalityLbEndpoints with empty locality (default)
 	healthyCount := 0
 	unhealthyInLbCount := 0
 	drainingCount := 0
@@ -225,14 +240,19 @@ func (b *EndpointBuilder) BuildClusterLoadAssignmentWithGateways(endpointIndex *
 	log.Infof("cluster %s [hostname=%s, port=%d] - total endpoints: %d [healthy=%d, unhealthy=%d, draining=%d, SupportsUnhealthyEndpoints=%v]",
 		b.clusterName, b.hostname, b.port, len(lbEndpoints), healthyCount, unhealthyInLbCount, drainingCount, b.service.SupportsUnhealthyEndpoints())
 
-	localityLbEndpoints := []*endpoint.LocalityLbEndpoints{
-		{
-			Locality:    &core.Locality{},
-			LbEndpoints: lbEndpoints,
-			LoadBalancingWeight: &wrapperspb.UInt32Value{
-				Value: uint32(len(lbEndpoints)),
-			},
-		},
+	localityNames := make([]string, 0, len(localities))
+	for name := range localities {
+		localityNames = append(localityNames, name)
+	}
+	sort.Strings(localityNames)
+	localityLbEndpoints := make([]*endpoint.LocalityLbEndpoints, 0, len(localityNames))
+	for _, name := range localityNames {
+		group := localities[name]
+		localityLbEndpoints = append(localityLbEndpoints, &endpoint.LocalityLbEndpoints{
+			Locality:            group.locality,
+			LbEndpoints:         group.endpoints,
+			LoadBalancingWeight: wrapperspb.UInt32(group.weight),
+		})
 	}
 
 	return &endpoint.ClusterLoadAssignment{
@@ -241,6 +261,21 @@ func (b *EndpointBuilder) BuildClusterLoadAssignmentWithGateways(endpointIndex *
 	}
 }
 
+func parseLocality(value string) *core.Locality {
+	parts := strings.SplitN(value, "/", 3)
+	locality := &core.Locality{}
+	if len(parts) > 0 {
+		locality.Region = parts[0]
+	}
+	if len(parts) > 1 {
+		locality.Zone = parts[1]
+	}
+	if len(parts) > 2 {
+		locality.SubZone = parts[2]
+	}
+	return locality
+}
+
 func (b *EndpointBuilder) servicePort(port int) *model.Port {
 	if !b.ServiceFound() {
 		return nil
@@ -295,6 +330,10 @@ func (b *EndpointBuilder) buildLbEndpointForCluster(ep *model.DubboEndpoint, end
 		healthStatus = core.HealthStatus_UNHEALTHY
 	}
 
+	weight := ep.LbWeight
+	if weight == 0 {
+		weight = 1
+	}
 	return &endpoint.LbEndpoint{
 		HostIdentifier: &endpoint.LbEndpoint_Endpoint{
 			Endpoint: &endpoint.Endpoint{
@@ -302,12 +341,27 @@ func (b *EndpointBuilder) buildLbEndpointForCluster(ep *model.DubboEndpoint, end
 			},
 		},
 		HealthStatus: healthStatus,
+		Metadata:     buildEndpointMetadata(ep),
 		LoadBalancingWeight: &wrapperspb.UInt32Value{
-			Value: 1,
+			Value: weight,
 		},
 	}
 }
 
+func buildEndpointMetadata(ep *model.DubboEndpoint) *core.Metadata {
+	if ep.Network == "" && ep.Locality == "" {
+		return nil
+	}
+	return &core.Metadata{FilterMetadata: map[string]*structpb.Struct{
+		"networking.dubbo.apache.org": {
+			Fields: map[string]*structpb.Value{
+				"network":  structpb.NewStringValue(ep.Network),
+				"locality": structpb.NewStringValue(ep.Locality),
+			},
+		},
+	}}
+}
+
 func (b *EndpointBuilder) eastWestGatewayForCluster(endpointCluster cluster.ID, gateways map[cluster.ID]multicluster.EastWestGateway) (multicluster.EastWestGateway, bool) {
 	if endpointCluster == "" || len(gateways) == 0 {
 		return multicluster.EastWestGateway{}, false
diff --git a/dubbod/discovery/pkg/xds/endpoints/endpoint_builder_test.go b/dubbod/discovery/pkg/xds/endpoints/endpoint_builder_test.go
index 06d38160..cf79c637 100644
--- a/dubbod/discovery/pkg/xds/endpoints/endpoint_builder_test.go
+++ b/dubbod/discovery/pkg/xds/endpoints/endpoint_builder_test.go
@@ -129,6 +129,55 @@ func TestBuildClusterLoadAssignmentKeepsLocalShardPodIPWhenGatewayConfigured(t *
 	}
 }
 
+func TestBuildClusterLoadAssignmentPreservesVMLocalityHealthAndWeight(t *testing.T) {
+	hostname := host.Name("reviews.mesh.local")
+	svc := newEndpointTestService("reviews", "bookinfo", string(hostname), 50051)
+	push := newEndpointTestPushContext(t, nil, []*model.Service{svc})
+	index := model.NewEndpointIndex(model.DisabledCache{})
+	index.UpdateServiceEndpoints(model.ShardKey{}, string(hostname), "bookinfo", []*model.DubboEndpoint{
+		{
+			Addresses:       []string{"10.0.0.10"},
+			EndpointPort:    50051,
+			ServicePortName: "http",
+			Locality:        "us-east-1/zone-a/rack-1",
+			LbWeight:        7,
+			HealthStatus:    model.Healthy,
+		},
+		{
+			Addresses:       []string{"10.0.0.11"},
+			EndpointPort:    50051,
+			ServicePortName: "http",
+			Locality:        "us-west-2/zone-b",
+			LbWeight:        3,
+			HealthStatus:    model.UnHealthy,
+		},
+	}, false)
+
+	clusterName := model.BuildSubsetKey(model.TrafficDirectionOutbound, "", hostname, 50051)
+	cla := NewEndpointBuilder(clusterName, newEndpointTestProxy(), push).BuildClusterLoadAssignment(index)
+	localities := cla.GetEndpoints()
+	if len(localities) != 2 {
+		t.Fatalf("localities = %d, want 2", len(localities))
+	}
+	if got := localities[0].GetLocality(); got.GetRegion() != "us-east-1" || got.GetZone() != "zone-a" || got.GetSubZone() != "rack-1" {
+		t.Fatalf("first locality = %v", got)
+	}
+	if got := localities[0].GetLoadBalancingWeight().GetValue(); got != 7 {
+		t.Fatalf("first locality weight = %d, want 7", got)
+	}
+	first := localities[0].GetLbEndpoints()[0]
+	metadata := first.GetMetadata().GetFilterMetadata()["networking.dubbo.apache.org"]
+	if got := metadata.GetFields()["locality"].GetStringValue(); got != "us-east-1/zone-a/rack-1" {
+		t.Fatalf("first endpoint locality metadata = %q", got)
+	}
+	if got := first.GetLoadBalancingWeight().GetValue(); got != 7 {
+		t.Fatalf("first endpoint weight = %d, want 7", got)
+	}
+	if got := localities[1].GetLbEndpoints()[0].GetHealthStatus(); got.String() != "UNHEALTHY" {
+		t.Fatalf("second endpoint health = %v, want UNHEALTHY", got)
+	}
+}
+
 func firstEndpointAddress(t *testing.T, cla *endpoint.ClusterLoadAssignment) string {
 	t.Helper()
 	localities := cla.GetEndpoints()
diff --git a/go.mod b/go.mod
index ccf5efc6..d4fc22e0 100644
--- a/go.mod
+++ b/go.mod
@@ -54,7 +54,7 @@ require (
 	github.com/heroku/color v0.0.6
 	github.com/kdubbo/api v0.0.0-20260713105721-558a18fe4f33
 	github.com/kdubbo/client-go v0.0.0-20260713105923-9a87feab8d96
-	github.com/kdubbo/xds-api v0.0.0-20260628124158-b93008db9162
+	github.com/kdubbo/xds-api v0.0.0-20260722141036-e1ddf72d7580
 	github.com/moby/moby/client v0.4.1
 	github.com/moby/term v0.5.2
 	github.com/ory/viper v1.7.5
diff --git a/go.sum b/go.sum
index f43e6713..eafa9299 100644
--- a/go.sum
+++ b/go.sum
@@ -372,8 +372,8 @@ github.com/kdubbo/api v0.0.0-20260713105721-558a18fe4f33 h1://TxoBFL/igcCqokYHOc
 github.com/kdubbo/api v0.0.0-20260713105721-558a18fe4f33/go.mod h1:8BtJiIovg7QCPsCxXcw3gDf922VcvYq5ihOSvj49Rq8=
 github.com/kdubbo/client-go v0.0.0-20260713105923-9a87feab8d96 h1:co2FdpetF4dIozVHMatAd2yYPtKBqdpCgSbpdnXEkKw=
 github.com/kdubbo/client-go v0.0.0-20260713105923-9a87feab8d96/go.mod h1:NUl5DTlsovJaoYDj0SyHi263H+1Z11EXP7qnk6TPKjo=
-github.com/kdubbo/xds-api v0.0.0-20260628124158-b93008db9162 h1:8T6FM/7KYiX84f8wMNmWxZ4YMv+uONCkZqTKiM6m/M8=
-github.com/kdubbo/xds-api v0.0.0-20260628124158-b93008db9162/go.mod h1:o2HDUgL1ntaDbWomZ4cD2tt8jBamuG2qRtjXOa1zZ0Q=
+github.com/kdubbo/xds-api v0.0.0-20260722141036-e1ddf72d7580 h1:pzUsk+g0CqS+gyBv11LEz1nlJUyfnWNVpGj7uiwG/4o=
+github.com/kdubbo/xds-api v0.0.0-20260722141036-e1ddf72d7580/go.mod h1:o2HDUgL1ntaDbWomZ4cD2tt8jBamuG2qRtjXOa1zZ0Q=
 github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
 github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
 github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
diff --git a/manifests/profiles/observability.yaml b/manifests/profiles/observability.yaml
deleted file mode 100644
index 64526b8e..00000000
--- a/manifests/profiles/observability.yaml
+++ /dev/null
@@ -1,39 +0,0 @@
-# Licensed to the Apache Software Foundation (ASF) under one or more
-# contributor license agreements.  See the NOTICE file distributed with
-# this work for additional information regarding copyright ownership.
-# The ASF licenses this file to You 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.
-
-# The observability profile installs only the observability addons
-# (Prometheus, Grafana, tracing, OpenTelemetry collector) on top of an
-# existing control plane installation.
-apiVersion: install.dubbo.apache.org/v1alpha1
-kind: DubboOperator
-metadata:
-  namespace: dubbo-system
-spec:
-  profile: observability
-  components:
-    base:
-      enabled: false
-    dubbod:
-      enabled: false
-    prometheus:
-      enabled: true
-    grafana:
-      enabled: true
-    tracing:
-      enabled: true
-    otelCollector:
-      enabled: true
-  values:
-    global: {}
diff --git a/pkg/config/validation/validators.go b/pkg/config/validation/validators.go
index 0b4fa91f..7d33922f 100644
--- a/pkg/config/validation/validators.go
+++ b/pkg/config/validation/validators.go
@@ -315,7 +315,7 @@ func validateWorkloadEntry(field string, workload *networking.WorkloadEntry) err
 		return fmt.Errorf("%s must not be null", field)
 	}
 	var errs error
-	if err := validateServiceEntryHost(field+".address", workload.GetAddress()); err != nil {
+	if err := validateWorkloadAddress(field+".address", workload.GetAddress()); err != nil {
 		errs = AppendErrors(errs, err)
 	}
 	for name, port := range workload.GetPorts() {
@@ -329,9 +329,33 @@ func validateWorkloadEntry(field string, workload *networking.WorkloadEntry) err
 	if err := labels.Instance(workload.GetLabels()).Validate(); err != nil {
 		errs = AppendErrors(errs, fmt.Errorf("%s.labels: %v", field, err))
 	}
+	if locality := workload.GetLocality(); locality != "" {
+		parts := strings.Split(locality, "/")
+		if len(parts) > 3 {
+			errs = AppendErrors(errs, fmt.Errorf("%s.locality %q must use region/zone/subzone form", field, locality))
+		}
+		for _, part := range parts {
+			if part == "" {
+				errs = AppendErrors(errs, fmt.Errorf("%s.locality %q must not contain empty segments", field, locality))
+				break
+			}
+		}
+	}
+	if serviceAccount := workload.GetServiceAccount(); serviceAccount != "" {
+		if messages := kvalidation.IsDNS1123Subdomain(serviceAccount); len(messages) > 0 {
+			errs = AppendErrors(errs, fmt.Errorf("%s.serviceAccount %q is invalid: %v", field, serviceAccount, messages))
+		}
+	}
 	return errs
 }
 
+func validateWorkloadAddress(field, value string) error {
+	if strings.Contains(value, "*") {
+		return fmt.Errorf("%s %q must not contain a wildcard", field, value)
+	}
+	return validateServiceEntryHost(field, value)
+}
+
 // ValidateTelemetry checks that a Telemetry resource is well-formed.
 var ValidateTelemetry = RegisterValidateFunc("ValidateTelemetry",
 	func(cfg config.Config) (Warning, error) {
diff --git a/pkg/config/validation/validators_test.go b/pkg/config/validation/validators_test.go
index c6276608..b661f062 100644
--- a/pkg/config/validation/validators_test.go
+++ b/pkg/config/validation/validators_test.go
@@ -342,10 +342,14 @@ func TestValidateWorkloadEntry(t *testing.T) {
 		wantErr bool
 	}{
 		{name: "valid IP", spec: &networking.WorkloadEntry{Address: "10.0.0.1", Ports: map[string]uint32{"grpc": 50051}}, wantErr: false},
-		{name: "valid DNS", spec: &networking.WorkloadEntry{Address: "vm.example.com"}, wantErr: false},
+		{name: "valid DNS", spec: &networking.WorkloadEntry{Address: "vm.example.com", Locality: "us-east-1/zone-a/rack-1", ServiceAccount: "reviews-vm"}, wantErr: false},
 		{name: "missing address", spec: &networking.WorkloadEntry{}, wantErr: true},
+		{name: "wildcard address", spec: &networking.WorkloadEntry{Address: "*.example.com"}, wantErr: true},
 		{name: "invalid port", spec: &networking.WorkloadEntry{Address: "10.0.0.1", Ports: map[string]uint32{"grpc": 0}}, wantErr: true},
 		{name: "invalid labels", spec: &networking.WorkloadEntry{Address: "10.0.0.1", Labels: map[string]string{"bad key": "value"}}, wantErr: true},
+		{name: "invalid locality empty segment", spec: &networking.WorkloadEntry{Address: "10.0.0.1", Locality: "us-east-1//rack-1"}, wantErr: true},
+		{name: "invalid locality depth", spec: &networking.WorkloadEntry{Address: "10.0.0.1", Locality: "region/zone/subzone/extra"}, wantErr: true},
+		{name: "invalid service account", spec: &networking.WorkloadEntry{Address: "10.0.0.1", ServiceAccount: "Bad_Account"}, wantErr: true},
 	}
 	for _, tc := range cases {
 		t.Run(tc.name, func(t *testing.T) {
diff --git a/samples/vm/README.md b/samples/vm/README.md
new file mode 100644
index 00000000..bad31d33
--- /dev/null
+++ b/samples/vm/README.md
@@ -0,0 +1,38 @@
+# Virtual machine workloads
+
+`WorkloadEntry` registers a VM, bare-metal process, or other non-Kubernetes workload as a mesh endpoint. `ServiceEntry` gives the selected workloads a stable service hostname.
+
+## Register a workload
+
+Set `spec.address` in `vm-service.yaml` to an address reachable from mesh clients, then apply it:
+
+```console
+kubectl apply -f samples/vm/vm-service.yaml
+```
+
+The workload port overrides the matching service port by name. `network`, `locality`, `weight`, and `serviceAccount` are propagated with the endpoint. A missing weight defaults to `1`.
+
+## Publish health
+
+VM health agents or external monitors can update the standard `Ready` or `Healthy` condition. A false or unknown condition publishes the endpoint as unhealthy through xDS; no condition preserves the backward-compatible healthy default.
+
+```console
+kubectl patch workloadentry payment-vm-01 --subresource=status --type=merge \
+  -p '{"status":{"conditions":[{"type":"Ready","status":"False","reason":"HealthCheckFailed"}]}}'
+```
+
+Restore the endpoint after recovery:
+
+```console
+kubectl patch workloadentry payment-vm-01 --subresource=status --type=merge \
+  -p '{"status":{"conditions":[{"type":"Ready","status":"True","reason":"HealthCheckPassed"}]}}'
+```
+
+Inspect the state published by the control plane:
+
+```console
+kubectl -n dubbo-system port-forward deploy/dubbod 18080:8080
+curl http://127.0.0.1:18080/debug/endpointz
+```
+
+The control plane manages discovery and routing. The VM process, network reachability, workload certificate provisioning, and the component that reports its health remain deployment responsibilities.
diff --git a/samples/vm/vm-service.yaml b/samples/vm/vm-service.yaml
new file mode 100644
index 00000000..32bf3074
--- /dev/null
+++ b/samples/vm/vm-service.yaml
@@ -0,0 +1,39 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0.
+
+apiVersion: networking.dubbo.apache.org/v1alpha3
+kind: WorkloadEntry
+metadata:
+  name: payment-vm-01
+  namespace: default
+spec:
+  address: 192.0.2.10
+  ports:
+    grpc: 50051
+  labels:
+    app: payment
+    version: v1
+  network: datacenter-1
+  locality: us-east-1/zone-a/rack-1
+  weight: 1
+  serviceAccount: payment
+---
+apiVersion: networking.dubbo.apache.org/v1alpha3
+kind: ServiceEntry
+metadata:
+  name: payment
+  namespace: default
+spec:
+  hosts:
+    - payment.mesh.local
+  location: MESH_INTERNAL
+  resolution: STATIC
+  ports:
+    - number: 50051
+      name: grpc
+      protocol: GRPC
+  workloadSelector:
+    matchLabels:
+      app: payment
diff --git a/tests/e2e/run.sh b/tests/e2e/run.sh
index 8029c309..672b9571 100755
--- a/tests/e2e/run.sh
+++ b/tests/e2e/run.sh
@@ -29,6 +29,8 @@
 #   UPGRADE_FROM_IMAGE   image expected by the previous chart (default: kdubbo/dubbod:debug)
 #   SKIP_BUILD      set to 1 to reuse an already-built ${IMAGE}
 #   KEEP_CLUSTER    set to 1 to keep the kind cluster after the run
+#   KIND            path to the kind binary      (default: kind)
+#   KIND_NODE_IMAGE kind node image override     (default: kind release default)
 
 set -euo pipefail
 
@@ -44,6 +46,8 @@ APP_NS="e2e"
 KUBECTL=(kubectl --context "kind-${CLUSTER_NAME}")
 UPGRADE_TMP_DIR=""
 PREVIOUS_CHART=""
+KIND="${KIND:-kind}"
+KIND_NODE_IMAGE="${KIND_NODE_IMAGE:-}"
 
 log() { echo "--- $*"; }
 
@@ -59,7 +63,7 @@ fail() {
 cleanup() {
   if [[ -n "${PF_PID:-}" ]]; then kill "${PF_PID}" 2>/dev/null || true; fi
   if [[ "${KEEP_CLUSTER:-0}" != "1" ]]; then
-    kind delete cluster --name "${CLUSTER_NAME}" || true
+    "${KIND}" delete cluster --name "${CLUSTER_NAME}" || true
   fi
   if [[ -n "${UPGRADE_TMP_DIR}" && "${UPGRADE_TMP_DIR}" == */dubbo-upgrade.* ]]; then
     rm -rf -- "${UPGRADE_TMP_DIR}"
@@ -106,15 +110,19 @@ if [[ "${IMAGE}" != "${UPGRADE_FROM_IMAGE}" ]]; then
   docker tag "${IMAGE}" "${UPGRADE_FROM_IMAGE}"
 fi
 
-if ! kind get clusters 2>/dev/null | grep -qx "${CLUSTER_NAME}"; then
+if ! "${KIND}" get clusters 2>/dev/null | grep -qx "${CLUSTER_NAME}"; then
   log "creating kind cluster ${CLUSTER_NAME}"
-  kind create cluster --name "${CLUSTER_NAME}" --wait 120s
+  KIND_CREATE_ARGS=(create cluster --name "${CLUSTER_NAME}" --wait 120s)
+  if [[ -n "${KIND_NODE_IMAGE}" ]]; then
+    KIND_CREATE_ARGS+=(--image "${KIND_NODE_IMAGE}")
+  fi
+  "${KIND}" "${KIND_CREATE_ARGS[@]}"
 fi
 
 log "loading ${IMAGE} into kind"
-kind load docker-image "${IMAGE}" --name "${CLUSTER_NAME}"
+"${KIND}" load docker-image "${IMAGE}" --name "${CLUSTER_NAME}"
 if [[ "${IMAGE}" != "${UPGRADE_FROM_IMAGE}" ]]; then
-  kind load docker-image "${UPGRADE_FROM_IMAGE}" --name "${CLUSTER_NAME}"
+  "${KIND}" load docker-image "${UPGRADE_FROM_IMAGE}" --name "${CLUSTER_NAME}"
 fi
 
 log "installing Gateway API CRDs"
@@ -184,6 +192,10 @@ log "applying ServiceEntry through the validating webhook"
 "${KUBECTL[@]}" -n "${APP_NS}" apply -f "${ROOT}/tests/e2e/testdata/serviceentry.yaml" \
   || fail "valid ServiceEntry was rejected"
 
+log "registering a VM workload through WorkloadEntry"
+"${KUBECTL[@]}" -n "${APP_NS}" apply -f "${ROOT}/tests/e2e/testdata/vm-workload.yaml" \
+  || fail "valid VM WorkloadEntry was rejected"
+
 log "port-forwarding dubbod monitoring port"
 "${KUBECTL[@]}" -n "${SYSTEM_NS}" port-forward deploy/dubbod 18080:8080 >/dev/null 2>&1 &
 PF_PID=$!
@@ -202,6 +214,12 @@ retry() {
 
 check_registry_service() { probe /debug/registryz | grep -q "httpbin.${APP_NS}.svc"; }
 check_registry_serviceentry() { probe /debug/registryz | grep -q "external.example.com"; }
+check_registry_vm() { probe /debug/registryz | grep -q "reviews-vm.mesh.local"; }
+check_vm_health() {
+  local health="$1"
+  probe /debug/endpointz | tr -d '\n ' \
+    | grep -q "\"hostname\":\"reviews-vm.mesh.local\".*\"address\":\"192.0.2.10\".*\"health\":\"${health}\".*\"network\":\"vm-network\".*\"locality\":\"us-east-1/zone-a/rack-1\".*\"weight\":7"
+}
 check_metrics() { probe /metrics | grep -q "^dubbod_"; }
 
 retry "monitoring endpoint up" probe /version
@@ -211,5 +229,20 @@ log "asserting httpbin service is in the registry"
 retry "httpbin in /debug/registryz" check_registry_service
 log "asserting ServiceEntry host is in the registry"
 retry "ServiceEntry in /debug/registryz" check_registry_serviceentry
+log "asserting VM service and endpoint topology are published"
+retry "VM ServiceEntry in /debug/registryz" check_registry_vm
+retry "healthy VM endpoint in /debug/endpointz" check_vm_health HEALTHY
+
+log "marking the VM endpoint unhealthy through the status subresource"
+"${KUBECTL[@]}" -n "${APP_NS}" patch workloadentry reviews-vm --subresource=status --type=merge \
+  -p '{"status":{"conditions":[{"type":"Ready","status":"False","reason":"E2EHealthCheck"}]}}' \
+  || fail "could not update VM health status"
+retry "unhealthy VM endpoint in /debug/endpointz" check_vm_health UNHEALTHY
+
+log "restoring the VM endpoint health"
+"${KUBECTL[@]}" -n "${APP_NS}" patch workloadentry reviews-vm --subresource=status --type=merge \
+  -p '{"status":{"conditions":[{"type":"Ready","status":"True","reason":"E2EHealthCheck"}]}}' \
+  || fail "could not restore VM health status"
+retry "recovered VM endpoint in /debug/endpointz" check_vm_health HEALTHY
 
 log "e2e smoke test passed"
diff --git a/tests/e2e/testdata/vm-workload.yaml b/tests/e2e/testdata/vm-workload.yaml
new file mode 100644
index 00000000..c3a26c5c
--- /dev/null
+++ b/tests/e2e/testdata/vm-workload.yaml
@@ -0,0 +1,36 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0.
+
+apiVersion: networking.dubbo.apache.org/v1alpha3
+kind: WorkloadEntry
+metadata:
+  name: reviews-vm
+spec:
+  address: 192.0.2.10
+  ports:
+    grpc: 50051
+  labels:
+    app: reviews-vm
+  network: vm-network
+  locality: us-east-1/zone-a/rack-1
+  weight: 7
+  serviceAccount: reviews-vm
+---
+apiVersion: networking.dubbo.apache.org/v1alpha3
+kind: ServiceEntry
+metadata:
+  name: reviews-vm
+spec:
+  hosts:
+    - reviews-vm.mesh.local
+  location: MESH_INTERNAL
+  resolution: STATIC
+  ports:
+    - number: 50051
+      name: grpc
+      protocol: GRPC
+  workloadSelector:
+    matchLabels:
+      app: reviews-vm