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 <[email protected]>
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
[](https://pkg.go.dev/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