This is an automated email from the ASF dual-hosted git repository.
mfordjody 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 077f7974 Complete controller wiring and gateway registration (#992)
077f7974 is described below
commit 077f79748e0f4be3b8732dac801d85391504a6f7
Author: mfordjody <[email protected]>
AuthorDate: Fri Aug 7 21:39:59 2026 +0800
Complete controller wiring and gateway registration (#992)
* Complete controller wiring and gateway registration
Complete controller wiring and gateway registration v2
fix ci
fix ci
fix ci
* fix ci
---
.gitattributes | 13 +
.github/workflows/ci.yml | 17 ++
dubbod/discovery/cmd/app/cmd.go | 4 +
dubbod/discovery/pkg/activation/controller.go | 135 ++++++++++
dubbod/discovery/pkg/activation/demand.go | 55 ++++
dubbod/discovery/pkg/activation/demand_service.go | 100 ++++++++
.../pkg/activation/demand_service_test.go | 270 ++++++++++++++++++++
dubbod/discovery/pkg/activation/demand_test.go | 64 +++++
.../discovery/pkg/activation/demandpb/demand.pb.go | 283 +++++++++++++++++++++
.../discovery/pkg/activation/demandpb/demand.proto | 75 ++++++
.../pkg/activation/demandpb/demand_grpc.pb.go | 162 ++++++++++++
dubbod/discovery/pkg/activation/server.go | 106 ++++++++
dubbod/discovery/pkg/activation/server_test.go | 94 +++++++
dubbod/discovery/pkg/bootstrap/activation.go | 49 ++++
dubbod/discovery/pkg/bootstrap/options.go | 3 +
dubbod/discovery/pkg/bootstrap/server.go | 6 +
manifests/charts/dubbod/templates/clusterrole.yaml | 7 +
manifests/charts/dubbod/templates/deployment.yaml | 21 ++
manifests/charts/dubbod/templates/service.yaml | 55 ++++
manifests/charts/dubbod/values.yaml | 5 +
operator/pkg/apis/proto/values_types.proto | 14 +
operator/pkg/apis/values_types.pb.go | 213 ++++++++++------
operator/pkg/render/manifest_test.go | 88 +++++++
tools/make/common.mk | 10 +
tools/make/lint.mk | 30 +++
25 files changed, 1807 insertions(+), 72 deletions(-)
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 00000000..ff76498f
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,13 @@
+# Generated files. Marked so they collapse in review diffs and are excluded
+# from language statistics; the source of truth is the .proto / metadata.yaml
+# beside them.
+#
+# They are deliberately left mergeable. Forcing a conflict on every generated
+# file would block a pull request whenever two branches touch the same API,
+# which is exactly when merging is most routine. `make check-generate` is the
+# real guard: it regenerates everything and fails with a diff if the committed
+# output no longer matches, so a bad merge or a hand-edit is caught there
+# rather than becoming a compile error in a file nobody edited.
+*.pb.go linguist-generated=true
+*.gen.go linguist-generated=true
+kubernetes/customresourcedefinitions.gen.yaml linguist-generated=true
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 57d73e96..c0344dc3 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -35,6 +35,10 @@ concurrency:
env:
GOLANGCI_LINT_VERSION: v2.6.2
+ # Must match PROTOC_VERSION in tools/make/common.mk: protoc stamps its own
+ # version into every generated file, so a different one here rewrites them
+ # all and make check-generate can never pass.
+ PROTOC_VERSION: v33.0
jobs:
lint:
@@ -83,6 +87,19 @@ jobs:
- name: Check go.mod/go.sum are tidy
run: make check-tidy
+ # Generated sources are never merged line by line (see .gitattributes);
+ # they are regenerated. This proves the committed output still matches
+ # what the generators produce, so a hand-edited or stale generated file
+ # fails here with a diff instead of later as a compile error.
+ - name: Install protoc
+ uses: arduino/setup-protoc@v3
+ with:
+ version: ${{ env.PROTOC_VERSION }}
+ repo-token: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Check generated sources are up to date
+ run: make check-generate
+
- name: Check License Header
uses: apache/skywalking-eyes/[email protected]
diff --git a/dubbod/discovery/cmd/app/cmd.go b/dubbod/discovery/cmd/app/cmd.go
index 09e3048d..2147b0c8 100644
--- a/dubbod/discovery/cmd/app/cmd.go
+++ b/dubbod/discovery/cmd/app/cmd.go
@@ -145,6 +145,10 @@ func addFlags(c *cobra.Command) {
"managementAddr",
":26080",
"Management API HTTP address")
+ c.PersistentFlags().StringVar(&serverArgs.ServerOptions.ActivationAddr,
+ "activationAddr",
+ ":26030",
+ "KEDA external scaler gRPC address for on-demand activation;
empty disables it")
c.PersistentFlags().StringVar(&serverArgs.ServerOptions.HTTPSAddr,
"httpsAddr",
":26017",
diff --git a/dubbod/discovery/pkg/activation/controller.go
b/dubbod/discovery/pkg/activation/controller.go
new file mode 100644
index 00000000..1a4bb324
--- /dev/null
+++ b/dubbod/discovery/pkg/activation/controller.go
@@ -0,0 +1,135 @@
+// 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 activation
+
+import (
+ "time"
+
+ "github.com/apache/dubbo-kubernetes/pkg/kube"
+ "github.com/apache/dubbo-kubernetes/pkg/kube/controllers"
+ "github.com/apache/dubbo-kubernetes/pkg/kube/kclient"
+ "github.com/apache/dubbo-kubernetes/pkg/log"
+ clientnetworking
"github.com/kdubbo/client-go/pkg/apis/networking/v1alpha3"
+ corev1 "k8s.io/api/core/v1"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ klabels "k8s.io/apimachinery/pkg/labels"
+ "k8s.io/apimachinery/pkg/types"
+)
+
+var logger = log.RegisterScope("activation", "Service activation policies")
+
+// resyncInterval re-evaluates every policy periodically. Two of the four
+// conditions are derived from live state that produces no Kubernetes event at
+// all — a KEDA stream opening, a gateway starting or stopping reports — so
+// without a tick they would stay stale until the policy itself changed.
+const resyncInterval = 30 * time.Second
+
+// Controller keeps ServiceActivationPolicy status in step with what the mesh
+// can actually do for that policy.
+//
+// It publishes status only. Replica counts belong to the autoscaler the policy
+// references, and writing them here would put two controllers on the same
+// field.
+type Controller struct {
+ policies kclient.Client[*clientnetworking.ServiceActivationPolicy]
+ services kclient.Client[*corev1.Service]
+ queue controllers.Queue
+
+ evaluator PolicyEvaluator
+}
+
+// NewController wires the policy watch to the live scaler and gateway state.
+func NewController(client kube.Client, streams StreamLookup, reporters
ReporterLookup) *Controller {
+ c := &Controller{
+ policies:
kclient.New[*clientnetworking.ServiceActivationPolicy](client),
+ services: kclient.New[*corev1.Service](client),
+ }
+ c.evaluator = PolicyEvaluator{
+ Services: c,
+ Streams: streams,
+ Reporters: reporters,
+ }
+
+ c.queue = controllers.NewQueue("service activation policy",
+ controllers.WithReconciler(c.Reconcile),
+ controllers.WithMaxAttempts(5))
+
+ c.policies.AddEventHandler(controllers.ObjectHandler(c.queue.AddObject))
+ // A policy is only accepted once its target Service exists, so a
Service
+ // appearing later has to re-open the policies that were rejected for
it.
+ c.services.AddEventHandler(controllers.ObjectHandler(func(o
controllers.Object) {
+ for _, policy := range c.policies.List(o.GetNamespace(),
klabels.Everything()) {
+ c.queue.AddObject(policy)
+ }
+ }))
+
+ return c
+}
+
+// HasService satisfies ServiceLookup from the informer cache.
+func (c *Controller) HasService(namespace, name string) bool {
+ return c.services.Get(name, namespace) != nil
+}
+
+func (c *Controller) Run(stop <-chan struct{}) {
+ kube.WaitForCacheSync("activation controller", stop,
c.policies.HasSynced, c.services.HasSynced)
+
+ go c.resync(stop)
+
+ c.queue.Run(stop)
+ controllers.ShutdownAll(c.policies, c.services)
+}
+
+// resync re-queues every policy on a tick, picking up scaler and gateway
+// changes that Kubernetes never reports.
+func (c *Controller) resync(stop <-chan struct{}) {
+ ticker := time.NewTicker(resyncInterval)
+ defer ticker.Stop()
+ for {
+ select {
+ case <-stop:
+ return
+ case <-ticker.C:
+ for _, policy := range
c.policies.List(metav1.NamespaceAll, klabels.Everything()) {
+ c.queue.AddObject(policy)
+ }
+ }
+ }
+}
+
+func (c *Controller) Reconcile(key types.NamespacedName) error {
+ policy := c.policies.Get(key.Name, key.Namespace)
+ if policy == nil {
+ // Deleted; nothing to publish.
+ return nil
+ }
+
+ conditions := c.evaluator.Evaluate(policy)
+ if SameConditions(policy.Status.GetConditions(), conditions) {
+ // Writing an unchanged status would feed the resync tick back
into
+ // itself and turn a quiet cluster into a steady write load.
+ return nil
+ }
+
+ updated := policy.DeepCopy()
+ updated.Status.Conditions = conditions
+ if _, err := c.policies.UpdateStatus(updated); err != nil {
+ return err
+ }
+
+ logger.Debugf("updated %s/%s: %s", key.Namespace, key.Name,
Summary(conditions))
+ return nil
+}
diff --git a/dubbod/discovery/pkg/activation/demand.go
b/dubbod/discovery/pkg/activation/demand.go
index 5c6e57eb..24d5d95f 100644
--- a/dubbod/discovery/pkg/activation/demand.go
+++ b/dubbod/discovery/pkg/activation/demand.go
@@ -100,6 +100,61 @@ func (r *Registry) Report(reporter string, target Target,
pending int64) {
notify(subscribers, total)
}
+// ReportSnapshot replaces everything a gateway previously reported.
+//
+// A target missing from the snapshot has drained at that gateway, so it must
be
+// cleared rather than left at its last value. Report cannot express that: it
+// only ever speaks about one target, and a gateway with nothing pending would
+// have no message to send.
+func (r *Registry) ReportSnapshot(reporter string, pending map[Target]int64) {
+ type notification struct {
+ subscribers []chan int64
+ total int64
+ }
+
+ r.mu.Lock()
+ now := r.now()
+ touched := map[Target]struct{}{}
+
+ // Drop this reporter from targets it no longer mentions.
+ for target, byReporter := range r.targets {
+ if _, ok := byReporter[reporter]; !ok {
+ continue
+ }
+ if _, still := pending[target]; still {
+ continue
+ }
+ delete(byReporter, reporter)
+ touched[target] = struct{}{}
+ }
+
+ for target, count := range pending {
+ if count < 0 {
+ count = 0
+ }
+ byReporter, ok := r.targets[target]
+ if !ok {
+ byReporter = map[string]report{}
+ r.targets[target] = byReporter
+ }
+ byReporter[reporter] = report{pending: count, received: now}
+ touched[target] = struct{}{}
+ }
+
+ notifications := make([]notification, 0, len(touched))
+ for target := range touched {
+ notifications = append(notifications, notification{
+ subscribers: r.snapshotSubscribersLocked(target),
+ total: r.totalLocked(target),
+ })
+ }
+ r.mu.Unlock()
+
+ for _, item := range notifications {
+ notify(item.subscribers, item.total)
+ }
+}
+
// Forget drops a gateway's reports, for a clean shutdown that should not wait
// out the TTL.
func (r *Registry) Forget(reporter string) {
diff --git a/dubbod/discovery/pkg/activation/demand_service.go
b/dubbod/discovery/pkg/activation/demand_service.go
new file mode 100644
index 00000000..e07b97e0
--- /dev/null
+++ b/dubbod/discovery/pkg/activation/demand_service.go
@@ -0,0 +1,100 @@
+// 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 activation
+
+import (
+ "errors"
+ "io"
+ "strings"
+
+
"github.com/apache/dubbo-kubernetes/dubbod/discovery/pkg/activation/demandpb"
+ "google.golang.org/grpc/codes"
+ "google.golang.org/grpc/status"
+)
+
+// DemandService receives the demand gateways broadcast to every control-plane
+// replica.
+type DemandService struct {
+ demandpb.UnimplementedActivationDemandServer
+
+ registry *Registry
+}
+
+func NewDemandService(registry *Registry) *DemandService {
+ return &DemandService{registry: registry}
+}
+
+// Report consumes one gateway's snapshot stream until it ends.
+//
+// The stream's lifetime is the gateway's liveness. On any exit the gateway is
+// forgotten immediately rather than left to age out, so a rolling gateway
+// update does not hold a workload scaled up for a full TTL after the old pod
+// is gone.
+func (s *DemandService) Report(stream demandpb.ActivationDemand_ReportServer)
error {
+ reporter := ""
+ var snapshots int64
+
+ defer func() {
+ if reporter != "" {
+ s.registry.Forget(reporter)
+ }
+ }()
+
+ for {
+ snapshot, err := stream.Recv()
+ if errors.Is(err, io.EOF) {
+ return
stream.SendAndClose(&demandpb.ReportSummary{Snapshots: snapshots})
+ }
+ if err != nil {
+ return err
+ }
+
+ name := strings.TrimSpace(snapshot.GetReporter())
+ if name == "" {
+ return status.Error(codes.InvalidArgument, "demand
snapshot is missing a reporter identity")
+ }
+ // Two identities on one stream would leave the first one's
demand
+ // behind with nothing refreshing it.
+ if reporter != "" && name != reporter {
+ return status.Errorf(codes.InvalidArgument,
+ "reporter changed mid-stream from %q to %q",
reporter, name)
+ }
+ reporter = name
+
+ pending, err := targetsOf(snapshot)
+ if err != nil {
+ return err
+ }
+ s.registry.ReportSnapshot(reporter, pending)
+ snapshots++
+ }
+}
+
+func targetsOf(snapshot *demandpb.DemandSnapshot) (map[Target]int64, error) {
+ pending := make(map[Target]int64, len(snapshot.GetTargets()))
+ for _, item := range snapshot.GetTargets() {
+ namespace := strings.TrimSpace(item.GetNamespace())
+ service := strings.TrimSpace(item.GetService())
+ if namespace == "" || service == "" {
+ return nil, status.Errorf(codes.InvalidArgument,
+ "demand target is missing a namespace or
service: %q/%q", namespace, service)
+ }
+ // Summed rather than overwritten: a malformed snapshot that
repeats a
+ // target must not silently discard one of the counts.
+ pending[Target{Namespace: namespace, Name: service}] +=
item.GetPending()
+ }
+ return pending, nil
+}
diff --git a/dubbod/discovery/pkg/activation/demand_service_test.go
b/dubbod/discovery/pkg/activation/demand_service_test.go
new file mode 100644
index 00000000..07227cf3
--- /dev/null
+++ b/dubbod/discovery/pkg/activation/demand_service_test.go
@@ -0,0 +1,270 @@
+// 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 activation
+
+import (
+ "context"
+ "net"
+ "testing"
+ "time"
+
+
"github.com/apache/dubbo-kubernetes/dubbod/discovery/pkg/activation/demandpb"
+ "google.golang.org/grpc"
+ "google.golang.org/grpc/codes"
+ "google.golang.org/grpc/credentials/insecure"
+ "google.golang.org/grpc/status"
+)
+
+var reviews = Target{Namespace: "app", Name: "reviews"}
+
+func startDemand(t *testing.T, registry *Registry)
demandpb.ActivationDemandClient {
+ t.Helper()
+
+ listener, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ server := grpc.NewServer()
+ demandpb.RegisterActivationDemandServer(server,
NewDemandService(registry))
+ go func() { _ = server.Serve(listener) }()
+
+ connection, err := grpc.NewClient(listener.Addr().String(),
+ grpc.WithTransportCredentials(insecure.NewCredentials()))
+ if err != nil {
+ server.Stop()
+ t.Fatal(err)
+ }
+ t.Cleanup(func() {
+ _ = connection.Close()
+ server.Stop()
+ })
+ return demandpb.NewActivationDemandClient(connection)
+}
+
+func snapshot(reporter string, targets ...*demandpb.TargetDemand)
*demandpb.DemandSnapshot {
+ return &demandpb.DemandSnapshot{Reporter: reporter, Targets: targets}
+}
+
+func demandFor(target Target, pending int64) *demandpb.TargetDemand {
+ return &demandpb.TargetDemand{
+ Namespace: target.Namespace,
+ Service: target.Name,
+ Pending: pending,
+ }
+}
+
+func waitFor(t *testing.T, condition func() bool, message string) {
+ t.Helper()
+ deadline := time.Now().Add(2 * time.Second)
+ for time.Now().Before(deadline) {
+ if condition() {
+ return
+ }
+ time.Sleep(time.Millisecond)
+ }
+ t.Fatal(message)
+}
+
+func TestReportStreamFeedsTheRegistry(t *testing.T) {
+ registry := NewRegistry()
+ client := startDemand(t, registry)
+
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+
+ stream, err := client.Report(ctx)
+ if err != nil {
+ t.Fatalf("Report() error = %v", err)
+ }
+ if err := stream.Send(snapshot("gateway-a", demandFor(orders, 3),
demandFor(reviews, 1))); err != nil {
+ t.Fatalf("Send() error = %v", err)
+ }
+ waitFor(t, func() bool { return registry.Pending(orders) == 3 &&
registry.Pending(reviews) == 1 },
+ "registry did not pick up the first snapshot")
+
+ // A target dropped from the snapshot has drained: there is no separate
+ // clear message, so its absence has to be what clears it.
+ if err := stream.Send(snapshot("gateway-a", demandFor(orders, 2))); err
!= nil {
+ t.Fatalf("Send() error = %v", err)
+ }
+ waitFor(t, func() bool { return registry.Pending(orders) == 2 &&
registry.Pending(reviews) == 0 },
+ "omitted target was not cleared")
+
+ summary, err := stream.CloseAndRecv()
+ if err != nil {
+ t.Fatalf("CloseAndRecv() error = %v", err)
+ }
+ if summary.GetSnapshots() != 2 {
+ t.Fatalf("snapshots = %d, want 2", summary.GetSnapshots())
+ }
+}
+
+// The stream is the gateway's liveness. When it ends the demand has to go with
+// it, or a rolling gateway update holds the workload up for a whole TTL after
+// the old pod is gone.
+func TestClosingTheStreamForgetsTheGateway(t *testing.T) {
+ registry := NewRegistry()
+ client := startDemand(t, registry)
+
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+
+ stream, err := client.Report(ctx)
+ if err != nil {
+ t.Fatalf("Report() error = %v", err)
+ }
+ if err := stream.Send(snapshot("gateway-a", demandFor(orders, 5))); err
!= nil {
+ t.Fatalf("Send() error = %v", err)
+ }
+ waitFor(t, func() bool { return registry.Pending(orders) == 5 },
"snapshot was not recorded")
+
+ if _, err := stream.CloseAndRecv(); err != nil {
+ t.Fatalf("CloseAndRecv() error = %v", err)
+ }
+ waitFor(t, func() bool { return registry.Pending(orders) == 0 },
+ "demand survived the stream closing")
+}
+
+// Every replica gets the same broadcast, so two gateways reporting the same
+// target must add up rather than overwrite one another.
+func TestReportsFromSeveralGatewaysAccumulate(t *testing.T) {
+ registry := NewRegistry()
+ client := startDemand(t, registry)
+
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+
+ first, err := client.Report(ctx)
+ if err != nil {
+ t.Fatal(err)
+ }
+ second, err := client.Report(ctx)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if err := first.Send(snapshot("gateway-a", demandFor(orders, 2))); err
!= nil {
+ t.Fatal(err)
+ }
+ if err := second.Send(snapshot("gateway-b", demandFor(orders, 3))); err
!= nil {
+ t.Fatal(err)
+ }
+ waitFor(t, func() bool { return registry.Pending(orders) == 5 },
+ "reports from two gateways did not accumulate")
+
+ if _, err := first.CloseAndRecv(); err != nil {
+ t.Fatal(err)
+ }
+ waitFor(t, func() bool { return registry.Pending(orders) == 3 },
+ "closing one gateway removed more than its own demand")
+}
+
+func TestReportRejectsMalformedSnapshots(t *testing.T) {
+ tests := []struct {
+ name string
+ send *demandpb.DemandSnapshot
+ }{
+ {name: "no reporter", send: snapshot("", demandFor(orders, 1))},
+ {name: "blank reporter", send: snapshot(" ",
demandFor(orders, 1))},
+ {
+ name: "target without namespace",
+ send: snapshot("gateway-a",
&demandpb.TargetDemand{Service: "orders", Pending: 1}),
+ },
+ {
+ name: "target without service",
+ send: snapshot("gateway-a",
&demandpb.TargetDemand{Namespace: "app", Pending: 1}),
+ },
+ }
+
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ client := startDemand(t, NewRegistry())
+ ctx, cancel :=
context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+
+ stream, err := client.Report(ctx)
+ if err != nil {
+ t.Fatal(err)
+ }
+ // Send may or may not observe the error depending on
when the
+ // server rejects it; CloseAndRecv is where it always
surfaces.
+ _ = stream.Send(test.send)
+ if _, err := stream.CloseAndRecv(); status.Code(err) !=
codes.InvalidArgument {
+ t.Fatalf("CloseAndRecv() error = %v, want
InvalidArgument", err)
+ }
+ })
+ }
+}
+
+// One stream is one gateway. Allowing a second identity would strand the
+// first one's demand with nothing left to refresh it.
+func TestReportRejectsAChangedReporter(t *testing.T) {
+ client := startDemand(t, NewRegistry())
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+
+ stream, err := client.Report(ctx)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := stream.Send(snapshot("gateway-a", demandFor(orders, 1))); err
!= nil {
+ t.Fatal(err)
+ }
+ _ = stream.Send(snapshot("gateway-b", demandFor(orders, 1)))
+
+ if _, err := stream.CloseAndRecv(); status.Code(err) !=
codes.InvalidArgument {
+ t.Fatalf("CloseAndRecv() error = %v, want InvalidArgument", err)
+ }
+}
+
+// Demand reported to this replica has to be visible to the KEDA stream this
+// replica is serving; that is the whole reason both live on one listener.
+func TestReportedDemandActivatesTheScalerOnTheSameReplica(t *testing.T) {
+ server := NewServer()
+
+ listener, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ address := listener.Addr().String()
+ _ = listener.Close()
+
+ stop := make(chan struct{})
+ if err := server.Serve(address, stop); err != nil {
+ t.Fatalf("Serve() error = %v", err)
+ }
+ t.Cleanup(func() { close(stop) })
+
+ connection, err := grpc.NewClient(address,
grpc.WithTransportCredentials(insecure.NewCredentials()))
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = connection.Close() })
+
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+
+ stream, err :=
demandpb.NewActivationDemandClient(connection).Report(ctx)
+ if err != nil {
+ t.Fatalf("Report() error = %v", err)
+ }
+ if err := stream.Send(snapshot("gateway-a", demandFor(orders, 1))); err
!= nil {
+ t.Fatal(err)
+ }
+
+ waitFor(t, func() bool { return server.Registry().Pending(orders) == 1
},
+ "demand did not reach the registry")
+}
diff --git a/dubbod/discovery/pkg/activation/demand_test.go
b/dubbod/discovery/pkg/activation/demand_test.go
index 4b661c8b..9d5bac43 100644
--- a/dubbod/discovery/pkg/activation/demand_test.go
+++ b/dubbod/discovery/pkg/activation/demand_test.go
@@ -215,3 +215,67 @@ func receive(t *testing.T, updates <-chan int64) int64 {
return 0
}
}
+
+func TestReportSnapshotReplacesTheWholeReporterView(t *testing.T) {
+ registry := NewRegistry()
+ reviews := Target{Namespace: "app", Name: "reviews"}
+
+ registry.ReportSnapshot("gateway-a", map[Target]int64{orders: 3,
reviews: 2})
+ if got := registry.Pending(orders); got != 3 {
+ t.Fatalf("orders pending = %d, want 3", got)
+ }
+ if got := registry.Pending(reviews); got != 2 {
+ t.Fatalf("reviews pending = %d, want 2", got)
+ }
+
+ // A target absent from the new snapshot has drained; there is no
separate
+ // clear message, so its absence is what has to clear it.
+ registry.ReportSnapshot("gateway-a", map[Target]int64{orders: 1})
+ if got := registry.Pending(orders); got != 1 {
+ t.Fatalf("orders pending after replacement = %d, want 1", got)
+ }
+ if got := registry.Pending(reviews); got != 0 {
+ t.Fatalf("reviews pending after omission = %d, want 0", got)
+ }
+
+ // An empty snapshot means the gateway drained everything.
+ registry.ReportSnapshot("gateway-a", nil)
+ if got := registry.Pending(orders); got != 0 {
+ t.Fatalf("orders pending after empty snapshot = %d, want 0",
got)
+ }
+}
+
+// Snapshots replace only the reporting gateway's own view; a broadcast from
+// one gateway must never clear another's demand.
+func TestReportSnapshotLeavesOtherReportersAlone(t *testing.T) {
+ registry := NewRegistry()
+
+ registry.ReportSnapshot("gateway-a", map[Target]int64{orders: 2})
+ registry.ReportSnapshot("gateway-b", map[Target]int64{orders: 3})
+ if got := registry.Pending(orders); got != 5 {
+ t.Fatalf("pending = %d, want 5", got)
+ }
+
+ registry.ReportSnapshot("gateway-a", nil)
+ if got := registry.Pending(orders); got != 3 {
+ t.Fatalf("pending after gateway-a drained = %d, want 3", got)
+ }
+}
+
+func TestReportSnapshotNotifiesSubscribers(t *testing.T) {
+ registry := NewRegistry()
+ updates, cancel := registry.Subscribe(orders)
+ defer cancel()
+
+ registry.ReportSnapshot("gateway-a", map[Target]int64{orders: 4})
+ if got := receive(t, updates); got != 4 {
+ t.Fatalf("update = %d, want 4", got)
+ }
+
+ // Dropping the target must wake the subscriber too, or a KEDA stream
would
+ // never learn the workload can scale back down.
+ registry.ReportSnapshot("gateway-a", nil)
+ if got := receive(t, updates); got != 0 {
+ t.Fatalf("drain update = %d, want 0", got)
+ }
+}
diff --git a/dubbod/discovery/pkg/activation/demandpb/demand.pb.go
b/dubbod/discovery/pkg/activation/demandpb/demand.pb.go
new file mode 100644
index 00000000..31a945dc
--- /dev/null
+++ b/dubbod/discovery/pkg/activation/demandpb/demand.pb.go
@@ -0,0 +1,283 @@
+// 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.
+//
+// Regenerate with:
+//
+// protoc -I . --go_out=. --go_opt=paths=source_relative \
+// --go-grpc_out=. --go-grpc_opt=paths=source_relative demand.proto
+
+// Code generated by protoc-gen-go. DO NOT EDIT.
+// versions:
+// protoc-gen-go v1.36.11
+// protoc v6.33.0
+// source: demand.proto
+
+package demandpb
+
+import (
+ protoreflect "google.golang.org/protobuf/reflect/protoreflect"
+ protoimpl "google.golang.org/protobuf/runtime/protoimpl"
+ reflect "reflect"
+ sync "sync"
+ unsafe "unsafe"
+)
+
+const (
+ // Verify that this generated code is sufficiently up-to-date.
+ _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
+ // Verify that runtime/protoimpl is sufficiently up-to-date.
+ _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
+)
+
+// DemandSnapshot is the complete set of targets one gateway is holding
+// requests for. It replaces that gateway's previous snapshot rather than
+// adding to it, which is what lets a lost or duplicated message converge on
+// the next send instead of skewing the total permanently.
+type DemandSnapshot struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ // Stable identity of the reporting gateway, unique across replicas.
Pod name
+ // is the expected value; two gateways sharing an identity would
overwrite
+ // each other's counts.
+ Reporter string `protobuf:"bytes,1,opt,name=reporter,proto3"
json:"reporter,omitempty"`
+ // Targets with requests waiting. A target absent from a snapshot has no
+ // pending requests at that gateway; there is no separate clear message.
+ Targets []*TargetDemand
`protobuf:"bytes,2,rep,name=targets,proto3" json:"targets,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *DemandSnapshot) Reset() {
+ *x = DemandSnapshot{}
+ mi := &file_demand_proto_msgTypes[0]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *DemandSnapshot) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*DemandSnapshot) ProtoMessage() {}
+
+func (x *DemandSnapshot) ProtoReflect() protoreflect.Message {
+ mi := &file_demand_proto_msgTypes[0]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use DemandSnapshot.ProtoReflect.Descriptor instead.
+func (*DemandSnapshot) Descriptor() ([]byte, []int) {
+ return file_demand_proto_rawDescGZIP(), []int{0}
+}
+
+func (x *DemandSnapshot) GetReporter() string {
+ if x != nil {
+ return x.Reporter
+ }
+ return ""
+}
+
+func (x *DemandSnapshot) GetTargets() []*TargetDemand {
+ if x != nil {
+ return x.Targets
+ }
+ return nil
+}
+
+type TargetDemand struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ // Namespace of the Service being activated.
+ Namespace string `protobuf:"bytes,1,opt,name=namespace,proto3"
json:"namespace,omitempty"`
+ // Name of the Service being activated.
+ Service string `protobuf:"bytes,2,opt,name=service,proto3"
json:"service,omitempty"`
+ // Requests this gateway is currently holding for the target.
+ Pending int64 `protobuf:"varint,3,opt,name=pending,proto3"
json:"pending,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *TargetDemand) Reset() {
+ *x = TargetDemand{}
+ mi := &file_demand_proto_msgTypes[1]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *TargetDemand) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*TargetDemand) ProtoMessage() {}
+
+func (x *TargetDemand) ProtoReflect() protoreflect.Message {
+ mi := &file_demand_proto_msgTypes[1]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use TargetDemand.ProtoReflect.Descriptor instead.
+func (*TargetDemand) Descriptor() ([]byte, []int) {
+ return file_demand_proto_rawDescGZIP(), []int{1}
+}
+
+func (x *TargetDemand) GetNamespace() string {
+ if x != nil {
+ return x.Namespace
+ }
+ return ""
+}
+
+func (x *TargetDemand) GetService() string {
+ if x != nil {
+ return x.Service
+ }
+ return ""
+}
+
+func (x *TargetDemand) GetPending() int64 {
+ if x != nil {
+ return x.Pending
+ }
+ return 0
+}
+
+// ReportSummary is returned once the stream closes. It exists so a gateway can
+// tell an orderly shutdown from a connection that was cut.
+type ReportSummary struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ // Number of snapshots accepted on the stream.
+ Snapshots int64 `protobuf:"varint,1,opt,name=snapshots,proto3"
json:"snapshots,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *ReportSummary) Reset() {
+ *x = ReportSummary{}
+ mi := &file_demand_proto_msgTypes[2]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *ReportSummary) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ReportSummary) ProtoMessage() {}
+
+func (x *ReportSummary) ProtoReflect() protoreflect.Message {
+ mi := &file_demand_proto_msgTypes[2]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use ReportSummary.ProtoReflect.Descriptor instead.
+func (*ReportSummary) Descriptor() ([]byte, []int) {
+ return file_demand_proto_rawDescGZIP(), []int{2}
+}
+
+func (x *ReportSummary) GetSnapshots() int64 {
+ if x != nil {
+ return x.Snapshots
+ }
+ return 0
+}
+
+var File_demand_proto protoreflect.FileDescriptor
+
+const file_demand_proto_rawDesc = "" +
+ "\n" +
+ "\fdemand.proto\x12\x19dubbo.activation.v1alpha1\"o\n" +
+ "\x0eDemandSnapshot\x12\x1a\n" +
+ "\breporter\x18\x01 \x01(\tR\breporter\x12A\n" +
+ "\atargets\x18\x02
\x03(\v2'.dubbo.activation.v1alpha1.TargetDemandR\atargets\"`\n" +
+ "\fTargetDemand\x12\x1c\n" +
+ "\tnamespace\x18\x01 \x01(\tR\tnamespace\x12\x18\n" +
+ "\aservice\x18\x02 \x01(\tR\aservice\x12\x18\n" +
+ "\apending\x18\x03 \x01(\x03R\apending\"-\n" +
+ "\rReportSummary\x12\x1c\n" +
+ "\tsnapshots\x18\x01 \x01(\x03R\tsnapshots2u\n" +
+ "\x10ActivationDemand\x12a\n" +
+
"\x06Report\x12).dubbo.activation.v1alpha1.DemandSnapshot\x1a(.dubbo.activation.v1alpha1.ReportSummary\"\x00(\x01BMZKgithub.com/apache/dubbo-kubernetes/dubbod/discovery/pkg/activation/demandpbb\x06proto3"
+
+var (
+ file_demand_proto_rawDescOnce sync.Once
+ file_demand_proto_rawDescData []byte
+)
+
+func file_demand_proto_rawDescGZIP() []byte {
+ file_demand_proto_rawDescOnce.Do(func() {
+ file_demand_proto_rawDescData =
protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_demand_proto_rawDesc),
len(file_demand_proto_rawDesc)))
+ })
+ return file_demand_proto_rawDescData
+}
+
+var file_demand_proto_msgTypes = make([]protoimpl.MessageInfo, 3)
+var file_demand_proto_goTypes = []any{
+ (*DemandSnapshot)(nil), // 0: dubbo.activation.v1alpha1.DemandSnapshot
+ (*TargetDemand)(nil), // 1: dubbo.activation.v1alpha1.TargetDemand
+ (*ReportSummary)(nil), // 2: dubbo.activation.v1alpha1.ReportSummary
+}
+var file_demand_proto_depIdxs = []int32{
+ 1, // 0: dubbo.activation.v1alpha1.DemandSnapshot.targets:type_name ->
dubbo.activation.v1alpha1.TargetDemand
+ 0, // 1: dubbo.activation.v1alpha1.ActivationDemand.Report:input_type
-> dubbo.activation.v1alpha1.DemandSnapshot
+ 2, // 2: dubbo.activation.v1alpha1.ActivationDemand.Report:output_type
-> dubbo.activation.v1alpha1.ReportSummary
+ 2, // [2:3] is the sub-list for method output_type
+ 1, // [1:2] is the sub-list for method input_type
+ 1, // [1:1] is the sub-list for extension type_name
+ 1, // [1:1] is the sub-list for extension extendee
+ 0, // [0:1] is the sub-list for field type_name
+}
+
+func init() { file_demand_proto_init() }
+func file_demand_proto_init() {
+ if File_demand_proto != nil {
+ return
+ }
+ type x struct{}
+ out := protoimpl.TypeBuilder{
+ File: protoimpl.DescBuilder{
+ GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
+ RawDescriptor:
unsafe.Slice(unsafe.StringData(file_demand_proto_rawDesc),
len(file_demand_proto_rawDesc)),
+ NumEnums: 0,
+ NumMessages: 3,
+ NumExtensions: 0,
+ NumServices: 1,
+ },
+ GoTypes: file_demand_proto_goTypes,
+ DependencyIndexes: file_demand_proto_depIdxs,
+ MessageInfos: file_demand_proto_msgTypes,
+ }.Build()
+ File_demand_proto = out.File
+ file_demand_proto_goTypes = nil
+ file_demand_proto_depIdxs = nil
+}
diff --git a/dubbod/discovery/pkg/activation/demandpb/demand.proto
b/dubbod/discovery/pkg/activation/demandpb/demand.proto
new file mode 100644
index 00000000..4f2604c6
--- /dev/null
+++ b/dubbod/discovery/pkg/activation/demandpb/demand.proto
@@ -0,0 +1,75 @@
+// 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.
+//
+// Regenerate with:
+//
+// protoc -I . --go_out=. --go_opt=paths=source_relative \
+// --go-grpc_out=. --go-grpc_opt=paths=source_relative demand.proto
+
+syntax = "proto3";
+
+package dubbo.activation.v1alpha1;
+
+option go_package =
"github.com/apache/dubbo-kubernetes/dubbod/discovery/pkg/activation/demandpb";
+
+// ActivationDemand carries the requests a gateway is holding for Services that
+// have no endpoints, so the control plane can tell KEDA to start them.
+//
+// A gateway must report to every control-plane replica, not just one. KEDA
+// opens its activation stream against a single replica chosen by the Service,
+// and a report that only reached a different replica would leave those
requests
+// waiting for a scale-up nobody asked for. Reports are idempotent snapshots
+// precisely so broadcasting them is safe.
+service ActivationDemand {
+ // Report streams demand snapshots for the lifetime of the gateway.
+ //
+ // The stream itself is the liveness signal: when it ends, the control plane
+ // drops that gateway's demand immediately instead of waiting for it to age
+ // out, so a gateway that shuts down cleanly cannot hold a workload up.
+ rpc Report(stream DemandSnapshot) returns (ReportSummary) {}
+}
+
+// DemandSnapshot is the complete set of targets one gateway is holding
+// requests for. It replaces that gateway's previous snapshot rather than
+// adding to it, which is what lets a lost or duplicated message converge on
+// the next send instead of skewing the total permanently.
+message DemandSnapshot {
+ // Stable identity of the reporting gateway, unique across replicas. Pod name
+ // is the expected value; two gateways sharing an identity would overwrite
+ // each other's counts.
+ string reporter = 1;
+
+ // Targets with requests waiting. A target absent from a snapshot has no
+ // pending requests at that gateway; there is no separate clear message.
+ repeated TargetDemand targets = 2;
+}
+
+message TargetDemand {
+ // Namespace of the Service being activated.
+ string namespace = 1;
+
+ // Name of the Service being activated.
+ string service = 2;
+
+ // Requests this gateway is currently holding for the target.
+ int64 pending = 3;
+}
+
+// ReportSummary is returned once the stream closes. It exists so a gateway can
+// tell an orderly shutdown from a connection that was cut.
+message ReportSummary {
+ // Number of snapshots accepted on the stream.
+ int64 snapshots = 1;
+}
diff --git a/dubbod/discovery/pkg/activation/demandpb/demand_grpc.pb.go
b/dubbod/discovery/pkg/activation/demandpb/demand_grpc.pb.go
new file mode 100644
index 00000000..b08cfb1a
--- /dev/null
+++ b/dubbod/discovery/pkg/activation/demandpb/demand_grpc.pb.go
@@ -0,0 +1,162 @@
+// 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.
+//
+// Regenerate with:
+//
+// protoc -I . --go_out=. --go_opt=paths=source_relative \
+// --go-grpc_out=. --go-grpc_opt=paths=source_relative demand.proto
+
+// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
+// versions:
+// - protoc-gen-go-grpc v1.6.2
+// - protoc v6.33.0
+// source: demand.proto
+
+package demandpb
+
+import (
+ context "context"
+ grpc "google.golang.org/grpc"
+ codes "google.golang.org/grpc/codes"
+ status "google.golang.org/grpc/status"
+)
+
+// This is a compile-time assertion to ensure that this generated file
+// is compatible with the grpc package it is being compiled against.
+// Requires gRPC-Go v1.64.0 or later.
+const _ = grpc.SupportPackageIsVersion9
+
+const (
+ ActivationDemand_Report_FullMethodName =
"/dubbo.activation.v1alpha1.ActivationDemand/Report"
+)
+
+// ActivationDemandClient is the client API for ActivationDemand service.
+//
+// For semantics around ctx use and closing/ending streaming RPCs, please
refer to
https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
+//
+// ActivationDemand carries the requests a gateway is holding for Services that
+// have no endpoints, so the control plane can tell KEDA to start them.
+//
+// A gateway must report to every control-plane replica, not just one. KEDA
+// opens its activation stream against a single replica chosen by the Service,
+// and a report that only reached a different replica would leave those
requests
+// waiting for a scale-up nobody asked for. Reports are idempotent snapshots
+// precisely so broadcasting them is safe.
+type ActivationDemandClient interface {
+ // Report streams demand snapshots for the lifetime of the gateway.
+ //
+ // The stream itself is the liveness signal: when it ends, the control
plane
+ // drops that gateway's demand immediately instead of waiting for it to
age
+ // out, so a gateway that shuts down cleanly cannot hold a workload up.
+ Report(ctx context.Context, opts ...grpc.CallOption)
(grpc.ClientStreamingClient[DemandSnapshot, ReportSummary], error)
+}
+
+type activationDemandClient struct {
+ cc grpc.ClientConnInterface
+}
+
+func NewActivationDemandClient(cc grpc.ClientConnInterface)
ActivationDemandClient {
+ return &activationDemandClient{cc}
+}
+
+func (c *activationDemandClient) Report(ctx context.Context, opts
...grpc.CallOption) (grpc.ClientStreamingClient[DemandSnapshot, ReportSummary],
error) {
+ cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
+ stream, err := c.cc.NewStream(ctx,
&ActivationDemand_ServiceDesc.Streams[0],
ActivationDemand_Report_FullMethodName, cOpts...)
+ if err != nil {
+ return nil, err
+ }
+ x := &grpc.GenericClientStream[DemandSnapshot,
ReportSummary]{ClientStream: stream}
+ return x, nil
+}
+
+// This type alias is provided for backwards compatibility with existing code
that references the prior non-generic stream type by name.
+type ActivationDemand_ReportClient =
grpc.ClientStreamingClient[DemandSnapshot, ReportSummary]
+
+// ActivationDemandServer is the server API for ActivationDemand service.
+// All implementations must embed UnimplementedActivationDemandServer
+// for forward compatibility.
+//
+// ActivationDemand carries the requests a gateway is holding for Services that
+// have no endpoints, so the control plane can tell KEDA to start them.
+//
+// A gateway must report to every control-plane replica, not just one. KEDA
+// opens its activation stream against a single replica chosen by the Service,
+// and a report that only reached a different replica would leave those
requests
+// waiting for a scale-up nobody asked for. Reports are idempotent snapshots
+// precisely so broadcasting them is safe.
+type ActivationDemandServer interface {
+ // Report streams demand snapshots for the lifetime of the gateway.
+ //
+ // The stream itself is the liveness signal: when it ends, the control
plane
+ // drops that gateway's demand immediately instead of waiting for it to
age
+ // out, so a gateway that shuts down cleanly cannot hold a workload up.
+ Report(grpc.ClientStreamingServer[DemandSnapshot, ReportSummary]) error
+ mustEmbedUnimplementedActivationDemandServer()
+}
+
+// UnimplementedActivationDemandServer must be embedded to have
+// forward compatible implementations.
+//
+// NOTE: this should be embedded by value instead of pointer to avoid a nil
+// pointer dereference when methods are called.
+type UnimplementedActivationDemandServer struct{}
+
+func (UnimplementedActivationDemandServer)
Report(grpc.ClientStreamingServer[DemandSnapshot, ReportSummary]) error {
+ return status.Error(codes.Unimplemented, "method Report not
implemented")
+}
+func (UnimplementedActivationDemandServer)
mustEmbedUnimplementedActivationDemandServer() {}
+func (UnimplementedActivationDemandServer) testEmbeddedByValue()
{}
+
+// UnsafeActivationDemandServer may be embedded to opt out of forward
compatibility for this service.
+// Use of this interface is not recommended, as added methods to
ActivationDemandServer will
+// result in compilation errors.
+type UnsafeActivationDemandServer interface {
+ mustEmbedUnimplementedActivationDemandServer()
+}
+
+func RegisterActivationDemandServer(s grpc.ServiceRegistrar, srv
ActivationDemandServer) {
+ // If the following call panics, it indicates
UnimplementedActivationDemandServer was
+ // embedded by pointer and is nil. This will cause panics if an
+ // unimplemented method is ever invoked, so we test this at
initialization
+ // time to prevent it from happening at runtime later due to I/O.
+ if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
+ t.testEmbeddedByValue()
+ }
+ s.RegisterService(&ActivationDemand_ServiceDesc, srv)
+}
+
+func _ActivationDemand_Report_Handler(srv interface{}, stream
grpc.ServerStream) error {
+ return
srv.(ActivationDemandServer).Report(&grpc.GenericServerStream[DemandSnapshot,
ReportSummary]{ServerStream: stream})
+}
+
+// This type alias is provided for backwards compatibility with existing code
that references the prior non-generic stream type by name.
+type ActivationDemand_ReportServer =
grpc.ClientStreamingServer[DemandSnapshot, ReportSummary]
+
+// ActivationDemand_ServiceDesc is the grpc.ServiceDesc for ActivationDemand
service.
+// It's only intended for direct use with grpc.RegisterService,
+// and not to be introspected or modified (even as a copy)
+var ActivationDemand_ServiceDesc = grpc.ServiceDesc{
+ ServiceName: "dubbo.activation.v1alpha1.ActivationDemand",
+ HandlerType: (*ActivationDemandServer)(nil),
+ Methods: []grpc.MethodDesc{},
+ Streams: []grpc.StreamDesc{
+ {
+ StreamName: "Report",
+ Handler: _ActivationDemand_Report_Handler,
+ ClientStreams: true,
+ },
+ },
+ Metadata: "demand.proto",
+}
diff --git a/dubbod/discovery/pkg/activation/server.go
b/dubbod/discovery/pkg/activation/server.go
new file mode 100644
index 00000000..6b8b0d1b
--- /dev/null
+++ b/dubbod/discovery/pkg/activation/server.go
@@ -0,0 +1,106 @@
+// 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 activation
+
+import (
+ "fmt"
+ "net"
+ "time"
+
+
"github.com/apache/dubbo-kubernetes/dubbod/discovery/pkg/activation/demandpb"
+
"github.com/apache/dubbo-kubernetes/dubbod/discovery/pkg/activation/externalscaler"
+ "google.golang.org/grpc"
+ "google.golang.org/grpc/keepalive"
+)
+
+// keepaliveOptions hold the StreamIsActive connections open through idle
+// periods. Those streams carry nothing between activations, and a middlebox
+// that drops idle connections would silently stop KEDA from ever hearing that
+// a request arrived.
+var keepaliveOptions = keepalive.ServerParameters{
+ Time: 30 * time.Second,
+ Timeout: 10 * time.Second,
+}
+
+// enforcementPolicy accepts the pings KEDA's client sends on an otherwise idle
+// activation stream. Without permitting them the server would close the very
+// connections it needs to keep.
+var enforcementPolicy = keepalive.EnforcementPolicy{
+ MinTime: 10 * time.Second,
+ PermitWithoutStream: true,
+}
+
+// Server is the KEDA-facing endpoint: a gRPC service KEDA dials for every
+// ScaledObject whose external trigger points at this control plane.
+type Server struct {
+ scaler *Scaler
+ registry *Registry
+ grpc *grpc.Server
+}
+
+// NewServer builds the activation endpoint and the demand registry behind it.
+func NewServer() *Server {
+ registry := NewRegistry()
+ scaler := NewScaler(registry)
+
+ server := grpc.NewServer(
+ grpc.KeepaliveParams(keepaliveOptions),
+ grpc.KeepaliveEnforcementPolicy(enforcementPolicy),
+ )
+ externalscaler.RegisterExternalScalerServer(server, scaler)
+ // Gateways report into the same registry the scaler reads, on the same
+ // listener: a gateway that can reach this replica can always be heard
by
+ // the KEDA stream this replica is serving.
+ demandpb.RegisterActivationDemandServer(server,
NewDemandService(registry))
+
+ return &Server{scaler: scaler, registry: registry, grpc: server}
+}
+
+// Scaler exposes the KEDA subscription state the policy controller reports.
+func (s *Server) Scaler() *Scaler { return s.scaler }
+
+// Registry exposes the demand store gateways report into.
+func (s *Server) Registry() *Registry { return s.registry }
+
+// Serve listens on addr until stop is closed. An empty address disables the
+// endpoint, which is how a cluster without KEDA installed runs unchanged.
+func (s *Server) Serve(addr string, stop <-chan struct{}) error {
+ if addr == "" {
+ logger.Info("activation scaler disabled; no listen address
configured")
+ return nil
+ }
+
+ listener, err := net.Listen("tcp", addr)
+ if err != nil {
+ return fmt.Errorf("unable to listen on activation scaler
socket: %v", err)
+ }
+
+ go func() {
+ logger.Infof("starting activation scaler at %s",
listener.Addr())
+ if err := s.grpc.Serve(listener); err != nil {
+ logger.Errorf("error serving activation scaler: %v",
err)
+ }
+ }()
+
+ go func() {
+ <-stop
+ // Graceful: an activation stream that is mid-send is the only
way KEDA
+ // learns about a waiting request, so it is worth letting it
finish.
+ s.grpc.GracefulStop()
+ }()
+
+ return nil
+}
diff --git a/dubbod/discovery/pkg/activation/server_test.go
b/dubbod/discovery/pkg/activation/server_test.go
new file mode 100644
index 00000000..62e81472
--- /dev/null
+++ b/dubbod/discovery/pkg/activation/server_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 activation
+
+import (
+ "context"
+ "net"
+ "testing"
+ "time"
+
+
"github.com/apache/dubbo-kubernetes/dubbod/discovery/pkg/activation/externalscaler"
+ "google.golang.org/grpc"
+ "google.golang.org/grpc/credentials/insecure"
+)
+
+// An empty address is how a cluster without KEDA runs unchanged: no listener,
+// no error, and the rest of the control plane comes up as usual.
+func TestServeWithoutAddressIsANoOp(t *testing.T) {
+ server := NewServer()
+ stop := make(chan struct{})
+ defer close(stop)
+
+ if err := server.Serve("", stop); err != nil {
+ t.Fatalf("Serve() with no address error = %v", err)
+ }
+}
+
+func TestServeReportsAnUnusableAddress(t *testing.T) {
+ // Occupy a port, then ask the scaler for the same one.
+ taken, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer func() { _ = taken.Close() }()
+
+ server := NewServer()
+ stop := make(chan struct{})
+ defer close(stop)
+
+ if err := server.Serve(taken.Addr().String(), stop); err == nil {
+ t.Fatal("Serve() on an occupied address returned no error")
+ }
+}
+
+// The registry the gateways report into and the scaler KEDA dials have to be
+// the same one, or demand would be recorded where nothing reads it.
+func TestServerSharesOneRegistryBetweenReportsAndScaler(t *testing.T) {
+ server := NewServer()
+
+ listener, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ address := listener.Addr().String()
+ _ = listener.Close()
+
+ stop := make(chan struct{})
+ if err := server.Serve(address, stop); err != nil {
+ t.Fatalf("Serve() error = %v", err)
+ }
+ t.Cleanup(func() { close(stop) })
+
+ connection, err := grpc.NewClient(address,
grpc.WithTransportCredentials(insecure.NewCredentials()))
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = connection.Close() })
+ client := externalscaler.NewExternalScalerClient(connection)
+
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+
+ server.Registry().Report("gateway-a", orders, 2)
+ active, err := client.IsActive(ctx, serviceRef())
+ if err != nil {
+ t.Fatalf("IsActive() error = %v", err)
+ }
+ if !active.GetResult() {
+ t.Fatal("IsActive() = false after demand was reported to the
server registry")
+ }
+}
diff --git a/dubbod/discovery/pkg/bootstrap/activation.go
b/dubbod/discovery/pkg/bootstrap/activation.go
new file mode 100644
index 00000000..08bd9b25
--- /dev/null
+++ b/dubbod/discovery/pkg/bootstrap/activation.go
@@ -0,0 +1,49 @@
+// 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 bootstrap
+
+import (
+ "github.com/apache/dubbo-kubernetes/dubbod/discovery/pkg/activation"
+ "github.com/apache/dubbo-kubernetes/pkg/log"
+)
+
+// initActivation starts the KEDA-facing scaler and the policy controller that
+// reports what it can actually do.
+//
+// Both are optional. Without a Kubernetes client there is nothing to watch,
and
+// without a listen address KEDA has nothing to dial; either way the rest of
the
+// control plane runs unchanged, because activation is a per-Service opt-in
+// rather than a mesh-wide dependency.
+func (s *Server) initActivation(args *DubboArgs) error {
+ s.activation = activation.NewServer()
+
+ if err := s.activation.Serve(args.ServerOptions.ActivationAddr,
s.internalStop); err != nil {
+ return err
+ }
+
+ if s.kubeClient == nil {
+ log.Info("activation policy controller disabled; no kube
client")
+ return nil
+ }
+
+ controller := activation.NewController(s.kubeClient,
s.activation.Scaler(), s.activation.Registry())
+ s.addStartFunc("activation policy controller", func(stop <-chan
struct{}) error {
+ go controller.Run(stop)
+ return nil
+ })
+
+ return nil
+}
diff --git a/dubbod/discovery/pkg/bootstrap/options.go
b/dubbod/discovery/pkg/bootstrap/options.go
index 5b3050fb..f5e9bbe5 100644
--- a/dubbod/discovery/pkg/bootstrap/options.go
+++ b/dubbod/discovery/pkg/bootstrap/options.go
@@ -57,6 +57,9 @@ type DiscoveryServerOptions struct {
HTTPSAddr string
GRPCAddr string
SecureGRPCAddr string
+ // ActivationAddr serves KEDA's external scaler contract. Empty
disables it,
+ // which is how a cluster without KEDA runs unchanged.
+ ActivationAddr string
TLSOptions TLSOptions
}
diff --git a/dubbod/discovery/pkg/bootstrap/server.go
b/dubbod/discovery/pkg/bootstrap/server.go
index 1db92cdb..34a206da 100644
--- a/dubbod/discovery/pkg/bootstrap/server.go
+++ b/dubbod/discovery/pkg/bootstrap/server.go
@@ -30,6 +30,7 @@ import (
"github.com/apache/dubbo-kubernetes/dubbod/discovery/pkg/status"
+ "github.com/apache/dubbo-kubernetes/dubbod/discovery/pkg/activation"
"github.com/apache/dubbo-kubernetes/dubbod/discovery/pkg/features"
dubbogrpc "github.com/apache/dubbo-kubernetes/dubbod/discovery/pkg/grpc"
"github.com/apache/dubbo-kubernetes/dubbod/discovery/pkg/keycertbundle"
@@ -130,6 +131,7 @@ type Server struct {
proxylessGRPCWorkloadController *proxylessGRPCWorkloadController
proxylessGRPCRemoteControllers
*multicluster.Component[*proxylessGRPCClusterController]
statusManager *status.Manager
+ activation *activation.Server
}
type readinessFlags struct {
@@ -248,6 +250,10 @@ func NewServer(args *DubboArgs, initFuncs
...func(*Server)) (*Server, error) {
return nil, fmt.Errorf("error initializing Management Server:
%v", err)
}
+ if err := s.initActivation(args); err != nil {
+ return nil, fmt.Errorf("error initializing activation scaler:
%v", err)
+ }
+
// Initialize monitoring server
if err := s.initMonitor(args.ServerOptions.HTTPAddr); err != nil {
return nil, fmt.Errorf("error initializing monitoring: %v", err)
diff --git a/manifests/charts/dubbod/templates/clusterrole.yaml
b/manifests/charts/dubbod/templates/clusterrole.yaml
index 04339413..412f6c5c 100644
--- a/manifests/charts/dubbod/templates/clusterrole.yaml
+++ b/manifests/charts/dubbod/templates/clusterrole.yaml
@@ -24,6 +24,13 @@ rules:
- apiGroups: [ "networking.dubbo.apache.org" ]
verbs: [ "get", "watch", "list" ]
resources: [ "*" ]
+ # The activation controller publishes readiness back onto the policy. Without
+ # the status subresource the conditions are computed and then silently
+ # dropped, leaving an operator with no way to tell a working policy from a
+ # broken one.
+ - apiGroups: [ "networking.dubbo.apache.org" ]
+ verbs: [ "get", "update", "patch" ]
+ resources: [ "serviceactivationpolicies/status" ]
- apiGroups: [ "telemetry.dubbo.apache.org" ]
verbs: [ "get", "watch", "list" ]
resources: [ "*" ]
diff --git a/manifests/charts/dubbod/templates/deployment.yaml
b/manifests/charts/dubbod/templates/deployment.yaml
index f11dc0f4..88dcbe02 100644
--- a/manifests/charts/dubbod/templates/deployment.yaml
+++ b/manifests/charts/dubbod/templates/deployment.yaml
@@ -29,6 +29,13 @@
{{- $remoteAccess := $multicluster.remoteAccess | default dict }}
{{- $eastWestGateway := $multicluster.eastWestGateway | default dict }}
{{- $managementPort := int (coalesce $management.port $defaultManagement.port
26080) }}
+{{- $defaultActivation := $defaultGlobal.activation | default dict }}
+{{- $activation := $global.activation | default dict }}
+{{- /* coalesce skips zero, so an explicit 0 has to be read with hasKey or the
off switch silently falls back to the default. */ -}}
+{{- $activationPort := int (coalesce $defaultActivation.port 26030) }}
+{{- if hasKey $activation "port" }}
+{{- $activationPort = int $activation.port }}
+{{- end }}
{{- $replicaCount := int (coalesce .Values.replicaCount $defaults.replicaCount
1) }}
{{- $gateway := $global.gateway | default dict }}
{{- $defaultGateway := $defaultGlobal.gateway | default dict }}
@@ -98,6 +105,15 @@ spec:
- cluster.local
- --managementAddr
- ":{{ $managementPort }}"
+{{- if gt $activationPort 0 }}
+ - --activationAddr
+ - ":{{ $activationPort }}"
+{{- else }}
+ # Port 0 turns the KEDA scaler off; an empty address is how dubbod
+ # skips the listener entirely.
+ - --activationAddr
+ - ""
+{{- end }}
ports:
- containerPort: 8080
protocol: TCP
@@ -105,6 +121,11 @@ spec:
- containerPort: {{ $managementPort }}
protocol: TCP
name: management
+{{- if gt $activationPort 0 }}
+ - containerPort: {{ $activationPort }}
+ protocol: TCP
+ name: activation
+{{- end }}
- containerPort: 26010
protocol: TCP
name: grpc-xds
diff --git a/manifests/charts/dubbod/templates/service.yaml
b/manifests/charts/dubbod/templates/service.yaml
index 7aa1ef49..f638a74b 100644
--- a/manifests/charts/dubbod/templates/service.yaml
+++ b/manifests/charts/dubbod/templates/service.yaml
@@ -23,6 +23,13 @@
{{- $multicluster := $global.multicluster | default dict }}
{{- $remoteAccess := $multicluster.remoteAccess | default dict }}
{{- $managementPort := int (coalesce $management.port $defaultManagement.port
26080) }}
+{{- $defaultActivation := $defaultGlobal.activation | default dict }}
+{{- $activation := $global.activation | default dict }}
+{{- /* coalesce skips zero, so an explicit 0 has to be read with hasKey or the
off switch silently falls back to the default. */ -}}
+{{- $activationPort := int (coalesce $defaultActivation.port 26030) }}
+{{- if hasKey $activation "port" }}
+{{- $activationPort = int $activation.port }}
+{{- end }}
{{- $remoteAccessEnabled := coalesce $remoteAccess.enabled
$defaultRemoteAccess.enabled false }}
{{- $remoteAccessServiceType := coalesce $remoteAccess.serviceType
$defaultRemoteAccess.serviceType "LoadBalancer" }}
{{- $remoteAccessGRPCPort := int (coalesce $remoteAccess.grpcPort
$defaultRemoteAccess.grpcPort 26010) }}
@@ -72,6 +79,54 @@ spec:
protocol: TCP
selector:
app: dubbod
+{{- if gt $activationPort 0 }}
+---
+# Separate Service because KEDA dials this by name from its own namespace; it
+# has no reason to see the management API, and the two scale independently.
+# Load balanced on purpose: KEDA needs exactly one replica to answer.
+apiVersion: v1
+kind: Service
+metadata:
+ name: dubbod-activation
+ namespace: dubbo-system
+ labels:
+ app: dubbod
+spec:
+ ports:
+ - port: {{ $activationPort }}
+ name: grpc-activation
+ targetPort: {{ $activationPort }}
+ protocol: TCP
+ selector:
+ app: dubbod
+---
+# Headless companion, for gateways rather than KEDA.
+#
+# A gateway must report its pending requests to every replica: KEDA's
+# activation stream lands on one replica chosen by the load-balanced Service
+# above, and a report that reached only a different replica would leave those
+# requests waiting for a scale-up nobody asked for. Resolving this name yields
+# one A record per pod, which is how a gateway finds them all.
+apiVersion: v1
+kind: Service
+metadata:
+ name: dubbod-activation-replicas
+ namespace: dubbo-system
+ labels:
+ app: dubbod
+spec:
+ clusterIP: None
+ # Report to replicas that are still starting as well: a gateway holding a
+ # request cannot wait for readiness to settle before asking for a scale-up.
+ publishNotReadyAddresses: true
+ ports:
+ - port: {{ $activationPort }}
+ name: grpc-activation
+ targetPort: {{ $activationPort }}
+ protocol: TCP
+ selector:
+ app: dubbod
+{{- end }}
{{- if $remoteAccessEnabled }}
---
apiVersion: v1
diff --git a/manifests/charts/dubbod/values.yaml
b/manifests/charts/dubbod/values.yaml
index b5731aff..04358956 100644
--- a/manifests/charts/dubbod/values.yaml
+++ b/manifests/charts/dubbod/values.yaml
@@ -54,6 +54,11 @@ _internal_default_values_not_set:
# gateway.dubbo.apache.org/replicas annotation.
replicaCount: 2
+ activation:
+ # KEDA external scaler for on-demand activation. Set port to 0 to disable
+ # it; the rest of the control plane is unaffected either way.
+ port: 26030
+
configValidation: true
multicluster:
diff --git a/operator/pkg/apis/proto/values_types.proto
b/operator/pkg/apis/proto/values_types.proto
index bcbeec79..75038ac7 100644
--- a/operator/pkg/apis/proto/values_types.proto
+++ b/operator/pkg/apis/proto/values_types.proto
@@ -28,6 +28,15 @@ message ManagementConfig {
int64 port = 1;
}
+message ActivationConfig {
+ // gRPC port serving KEDA's external scaler contract. Zero disables it.
+ //
+ // Wrapped so an explicit 0 is distinguishable from an unset field: a bare
+ // int64 drops its zero value, which would make the off switch unreachable
+ // through the operator API.
+ google.protobuf.Int64Value port = 1;
+}
+
message ProxyConfig {
string clusterDomain = 1;
}
@@ -80,6 +89,11 @@ message GlobalConfig {
ProxylessConfig proxyless = 6;
GatewayConfig gateway = 7;
+
+ // Field numbers are part of the wire format, so two branches must never
+ // claim the same one: 7 already belongs to gateway, and reusing it would
+ // produce a proto that compiles while silently reinterpreting data.
+ ActivationConfig activation = 8;
}
message RemoteAccessConfig {
diff --git a/operator/pkg/apis/values_types.pb.go
b/operator/pkg/apis/values_types.pb.go
index 03c82c28..7aef78d3 100644
--- a/operator/pkg/apis/values_types.pb.go
+++ b/operator/pkg/apis/values_types.pb.go
@@ -82,6 +82,55 @@ func (x *ManagementConfig) GetPort() int64 {
return 0
}
+type ActivationConfig struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ // gRPC port serving KEDA's external scaler contract. Zero disables it.
+ //
+ // Wrapped so an explicit 0 is distinguishable from an unset field: a
bare
+ // int64 drops its zero value, which would make the off switch
unreachable
+ // through the operator API.
+ Port *wrapperspb.Int64Value
`protobuf:"bytes,1,opt,name=port,proto3" json:"port,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *ActivationConfig) Reset() {
+ *x = ActivationConfig{}
+ mi := &file_values_types_proto_msgTypes[1]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *ActivationConfig) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ActivationConfig) ProtoMessage() {}
+
+func (x *ActivationConfig) ProtoReflect() protoreflect.Message {
+ mi := &file_values_types_proto_msgTypes[1]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use ActivationConfig.ProtoReflect.Descriptor instead.
+func (*ActivationConfig) Descriptor() ([]byte, []int) {
+ return file_values_types_proto_rawDescGZIP(), []int{1}
+}
+
+func (x *ActivationConfig) GetPort() *wrapperspb.Int64Value {
+ if x != nil {
+ return x.Port
+ }
+ return nil
+}
+
type ProxyConfig struct {
state protoimpl.MessageState `protogen:"open.v1"`
ClusterDomain string
`protobuf:"bytes,1,opt,name=clusterDomain,proto3"
json:"clusterDomain,omitempty"`
@@ -91,7 +140,7 @@ type ProxyConfig struct {
func (x *ProxyConfig) Reset() {
*x = ProxyConfig{}
- mi := &file_values_types_proto_msgTypes[1]
+ mi := &file_values_types_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -103,7 +152,7 @@ func (x *ProxyConfig) String() string {
func (*ProxyConfig) ProtoMessage() {}
func (x *ProxyConfig) ProtoReflect() protoreflect.Message {
- mi := &file_values_types_proto_msgTypes[1]
+ mi := &file_values_types_proto_msgTypes[2]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -116,7 +165,7 @@ func (x *ProxyConfig) ProtoReflect() protoreflect.Message {
// Deprecated: Use ProxyConfig.ProtoReflect.Descriptor instead.
func (*ProxyConfig) Descriptor() ([]byte, []int) {
- return file_values_types_proto_rawDescGZIP(), []int{1}
+ return file_values_types_proto_rawDescGZIP(), []int{2}
}
func (x *ProxyConfig) GetClusterDomain() string {
@@ -135,7 +184,7 @@ type ProxylessConfig struct {
func (x *ProxylessConfig) Reset() {
*x = ProxylessConfig{}
- mi := &file_values_types_proto_msgTypes[2]
+ mi := &file_values_types_proto_msgTypes[3]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -147,7 +196,7 @@ func (x *ProxylessConfig) String() string {
func (*ProxylessConfig) ProtoMessage() {}
func (x *ProxylessConfig) ProtoReflect() protoreflect.Message {
- mi := &file_values_types_proto_msgTypes[2]
+ mi := &file_values_types_proto_msgTypes[3]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -160,7 +209,7 @@ func (x *ProxylessConfig) ProtoReflect()
protoreflect.Message {
// Deprecated: Use ProxylessConfig.ProtoReflect.Descriptor instead.
func (*ProxylessConfig) Descriptor() ([]byte, []int) {
- return file_values_types_proto_rawDescGZIP(), []int{2}
+ return file_values_types_proto_rawDescGZIP(), []int{3}
}
func (x *ProxylessConfig) GetCni() *MeshCNIConfig {
@@ -189,7 +238,7 @@ type MeshCNIConfig struct {
func (x *MeshCNIConfig) Reset() {
*x = MeshCNIConfig{}
- mi := &file_values_types_proto_msgTypes[3]
+ mi := &file_values_types_proto_msgTypes[4]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -201,7 +250,7 @@ func (x *MeshCNIConfig) String() string {
func (*MeshCNIConfig) ProtoMessage() {}
func (x *MeshCNIConfig) ProtoReflect() protoreflect.Message {
- mi := &file_values_types_proto_msgTypes[3]
+ mi := &file_values_types_proto_msgTypes[4]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -214,7 +263,7 @@ func (x *MeshCNIConfig) ProtoReflect() protoreflect.Message
{
// Deprecated: Use MeshCNIConfig.ProtoReflect.Descriptor instead.
func (*MeshCNIConfig) Descriptor() ([]byte, []int) {
- return file_values_types_proto_rawDescGZIP(), []int{3}
+ return file_values_types_proto_rawDescGZIP(), []int{4}
}
func (x *MeshCNIConfig) GetEnabled() bool {
@@ -305,7 +354,7 @@ type GatewayConfig struct {
func (x *GatewayConfig) Reset() {
*x = GatewayConfig{}
- mi := &file_values_types_proto_msgTypes[4]
+ mi := &file_values_types_proto_msgTypes[5]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -317,7 +366,7 @@ func (x *GatewayConfig) String() string {
func (*GatewayConfig) ProtoMessage() {}
func (x *GatewayConfig) ProtoReflect() protoreflect.Message {
- mi := &file_values_types_proto_msgTypes[4]
+ mi := &file_values_types_proto_msgTypes[5]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -330,7 +379,7 @@ func (x *GatewayConfig) ProtoReflect() protoreflect.Message
{
// Deprecated: Use GatewayConfig.ProtoReflect.Descriptor instead.
func (*GatewayConfig) Descriptor() ([]byte, []int) {
- return file_values_types_proto_rawDescGZIP(), []int{4}
+ return file_values_types_proto_rawDescGZIP(), []int{5}
}
func (x *GatewayConfig) GetReplicaCount() *wrapperspb.Int32Value {
@@ -349,13 +398,17 @@ type GlobalConfig struct {
Multicluster *MulticlusterConfig
`protobuf:"bytes,5,opt,name=multicluster,proto3" json:"multicluster,omitempty"`
Proxyless *ProxylessConfig
`protobuf:"bytes,6,opt,name=proxyless,proto3" json:"proxyless,omitempty"`
Gateway *GatewayConfig
`protobuf:"bytes,7,opt,name=gateway,proto3" json:"gateway,omitempty"`
- unknownFields protoimpl.UnknownFields
- sizeCache protoimpl.SizeCache
+ // Field numbers are part of the wire format, so two branches must never
+ // claim the same one: 7 already belongs to gateway, and reusing it
would
+ // produce a proto that compiles while silently reinterpreting data.
+ Activation *ActivationConfig
`protobuf:"bytes,8,opt,name=activation,proto3" json:"activation,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
}
func (x *GlobalConfig) Reset() {
*x = GlobalConfig{}
- mi := &file_values_types_proto_msgTypes[5]
+ mi := &file_values_types_proto_msgTypes[6]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -367,7 +420,7 @@ func (x *GlobalConfig) String() string {
func (*GlobalConfig) ProtoMessage() {}
func (x *GlobalConfig) ProtoReflect() protoreflect.Message {
- mi := &file_values_types_proto_msgTypes[5]
+ mi := &file_values_types_proto_msgTypes[6]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -380,7 +433,7 @@ func (x *GlobalConfig) ProtoReflect() protoreflect.Message {
// Deprecated: Use GlobalConfig.ProtoReflect.Descriptor instead.
func (*GlobalConfig) Descriptor() ([]byte, []int) {
- return file_values_types_proto_rawDescGZIP(), []int{5}
+ return file_values_types_proto_rawDescGZIP(), []int{6}
}
func (x *GlobalConfig) GetProxy() *ProxyConfig {
@@ -432,6 +485,13 @@ func (x *GlobalConfig) GetGateway() *GatewayConfig {
return nil
}
+func (x *GlobalConfig) GetActivation() *ActivationConfig {
+ if x != nil {
+ return x.Activation
+ }
+ return nil
+}
+
type RemoteAccessConfig struct {
state protoimpl.MessageState `protogen:"open.v1"`
Enabled bool
`protobuf:"varint,1,opt,name=enabled,proto3" json:"enabled,omitempty"`
@@ -446,7 +506,7 @@ type RemoteAccessConfig struct {
func (x *RemoteAccessConfig) Reset() {
*x = RemoteAccessConfig{}
- mi := &file_values_types_proto_msgTypes[6]
+ mi := &file_values_types_proto_msgTypes[7]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -458,7 +518,7 @@ func (x *RemoteAccessConfig) String() string {
func (*RemoteAccessConfig) ProtoMessage() {}
func (x *RemoteAccessConfig) ProtoReflect() protoreflect.Message {
- mi := &file_values_types_proto_msgTypes[6]
+ mi := &file_values_types_proto_msgTypes[7]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -471,7 +531,7 @@ func (x *RemoteAccessConfig) ProtoReflect()
protoreflect.Message {
// Deprecated: Use RemoteAccessConfig.ProtoReflect.Descriptor instead.
func (*RemoteAccessConfig) Descriptor() ([]byte, []int) {
- return file_values_types_proto_rawDescGZIP(), []int{6}
+ return file_values_types_proto_rawDescGZIP(), []int{7}
}
func (x *RemoteAccessConfig) GetEnabled() bool {
@@ -527,7 +587,7 @@ type EastWestGatewayEndpoint struct {
func (x *EastWestGatewayEndpoint) Reset() {
*x = EastWestGatewayEndpoint{}
- mi := &file_values_types_proto_msgTypes[7]
+ mi := &file_values_types_proto_msgTypes[8]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -539,7 +599,7 @@ func (x *EastWestGatewayEndpoint) String() string {
func (*EastWestGatewayEndpoint) ProtoMessage() {}
func (x *EastWestGatewayEndpoint) ProtoReflect() protoreflect.Message {
- mi := &file_values_types_proto_msgTypes[7]
+ mi := &file_values_types_proto_msgTypes[8]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -552,7 +612,7 @@ func (x *EastWestGatewayEndpoint) ProtoReflect()
protoreflect.Message {
// Deprecated: Use EastWestGatewayEndpoint.ProtoReflect.Descriptor instead.
func (*EastWestGatewayEndpoint) Descriptor() ([]byte, []int) {
- return file_values_types_proto_rawDescGZIP(), []int{7}
+ return file_values_types_proto_rawDescGZIP(), []int{8}
}
func (x *EastWestGatewayEndpoint) GetClusterName() string {
@@ -591,7 +651,7 @@ type EastWestGatewayConfig struct {
func (x *EastWestGatewayConfig) Reset() {
*x = EastWestGatewayConfig{}
- mi := &file_values_types_proto_msgTypes[8]
+ mi := &file_values_types_proto_msgTypes[9]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -603,7 +663,7 @@ func (x *EastWestGatewayConfig) String() string {
func (*EastWestGatewayConfig) ProtoMessage() {}
func (x *EastWestGatewayConfig) ProtoReflect() protoreflect.Message {
- mi := &file_values_types_proto_msgTypes[8]
+ mi := &file_values_types_proto_msgTypes[9]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -616,7 +676,7 @@ func (x *EastWestGatewayConfig) ProtoReflect()
protoreflect.Message {
// Deprecated: Use EastWestGatewayConfig.ProtoReflect.Descriptor instead.
func (*EastWestGatewayConfig) Descriptor() ([]byte, []int) {
- return file_values_types_proto_rawDescGZIP(), []int{8}
+ return file_values_types_proto_rawDescGZIP(), []int{9}
}
func (x *EastWestGatewayConfig) GetEnabled() bool {
@@ -678,7 +738,7 @@ type MulticlusterConfig struct {
func (x *MulticlusterConfig) Reset() {
*x = MulticlusterConfig{}
- mi := &file_values_types_proto_msgTypes[9]
+ mi := &file_values_types_proto_msgTypes[10]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -690,7 +750,7 @@ func (x *MulticlusterConfig) String() string {
func (*MulticlusterConfig) ProtoMessage() {}
func (x *MulticlusterConfig) ProtoReflect() protoreflect.Message {
- mi := &file_values_types_proto_msgTypes[9]
+ mi := &file_values_types_proto_msgTypes[10]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -703,7 +763,7 @@ func (x *MulticlusterConfig) ProtoReflect()
protoreflect.Message {
// Deprecated: Use MulticlusterConfig.ProtoReflect.Descriptor instead.
func (*MulticlusterConfig) Descriptor() ([]byte, []int) {
- return file_values_types_proto_rawDescGZIP(), []int{9}
+ return file_values_types_proto_rawDescGZIP(), []int{10}
}
func (x *MulticlusterConfig) GetRemoteAccess() *RemoteAccessConfig {
@@ -736,7 +796,7 @@ type Values struct {
func (x *Values) Reset() {
*x = Values{}
- mi := &file_values_types_proto_msgTypes[10]
+ mi := &file_values_types_proto_msgTypes[11]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -748,7 +808,7 @@ func (x *Values) String() string {
func (*Values) ProtoMessage() {}
func (x *Values) ProtoReflect() protoreflect.Message {
- mi := &file_values_types_proto_msgTypes[10]
+ mi := &file_values_types_proto_msgTypes[11]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -761,7 +821,7 @@ func (x *Values) ProtoReflect() protoreflect.Message {
// Deprecated: Use Values.ProtoReflect.Descriptor instead.
func (*Values) Descriptor() ([]byte, []int) {
- return file_values_types_proto_rawDescGZIP(), []int{10}
+ return file_values_types_proto_rawDescGZIP(), []int{11}
}
func (x *Values) GetGlobal() *GlobalConfig {
@@ -805,7 +865,7 @@ type IntOrString struct {
func (x *IntOrString) Reset() {
*x = IntOrString{}
- mi := &file_values_types_proto_msgTypes[11]
+ mi := &file_values_types_proto_msgTypes[12]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -817,7 +877,7 @@ func (x *IntOrString) String() string {
func (*IntOrString) ProtoMessage() {}
func (x *IntOrString) ProtoReflect() protoreflect.Message {
- mi := &file_values_types_proto_msgTypes[11]
+ mi := &file_values_types_proto_msgTypes[12]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -830,7 +890,7 @@ func (x *IntOrString) ProtoReflect() protoreflect.Message {
// Deprecated: Use IntOrString.ProtoReflect.Descriptor instead.
func (*IntOrString) Descriptor() ([]byte, []int) {
- return file_values_types_proto_rawDescGZIP(), []int{11}
+ return file_values_types_proto_rawDescGZIP(), []int{12}
}
func (x *IntOrString) GetType() int64 {
@@ -860,7 +920,9 @@ const file_values_types_proto_rawDesc = "" +
"\n" +
"\x12values_types.proto\x12\x17dubbo.operator.v1alpha1\x1a\x1egoogle/protobuf/wrappers.proto\"&\n"
+
"\x10ManagementConfig\x12\x12\n" +
- "\x04port\x18\x01 \x01(\x03R\x04port\"3\n" +
+ "\x04port\x18\x01 \x01(\x03R\x04port\"C\n" +
+ "\x10ActivationConfig\x12/\n" +
+ "\x04port\x18\x01
\x01(\v2\x1b.google.protobuf.Int64ValueR\x04port\"3\n" +
"\vProxyConfig\x12$\n" +
"\rclusterDomain\x18\x01 \x01(\tR\rclusterDomain\"K\n" +
"\x0fProxylessConfig\x128\n" +
@@ -879,7 +941,7 @@ const file_values_types_proto_rawDesc = "" +
" \x01(\tR\tipsetPath\x12(\n" +
"\x0frefreshInterval\x18\v \x01(\tR\x0frefreshInterval\"P\n" +
"\rGatewayConfig\x12?\n" +
- "\freplicaCount\x18\x01
\x01(\v2\x1b.google.protobuf.Int32ValueR\freplicaCount\"\xbc\x03\n" +
+ "\freplicaCount\x18\x01
\x01(\v2\x1b.google.protobuf.Int32ValueR\freplicaCount\"\x87\x04\n" +
"\fGlobalConfig\x12:\n" +
"\x05proxy\x18\x01
\x01(\v2$.dubbo.operator.v1alpha1.ProxyConfigR\x05proxy\x12\x1e\n" +
"\n" +
@@ -891,7 +953,10 @@ const file_values_types_proto_rawDesc = "" +
"\x10configValidation\x18\x04 \x01(\bR\x10configValidation\x12O\n" +
"\fmulticluster\x18\x05
\x01(\v2+.dubbo.operator.v1alpha1.MulticlusterConfigR\fmulticluster\x12F\n" +
"\tproxyless\x18\x06
\x01(\v2(.dubbo.operator.v1alpha1.ProxylessConfigR\tproxyless\x12@\n" +
- "\agateway\x18\a
\x01(\v2&.dubbo.operator.v1alpha1.GatewayConfigR\agateway\"\xd4\x01\n" +
+ "\agateway\x18\a
\x01(\v2&.dubbo.operator.v1alpha1.GatewayConfigR\agateway\x12I\n" +
+ "\n" +
+ "activation\x18\b
\x01(\v2).dubbo.operator.v1alpha1.ActivationConfigR\n" +
+ "activation\"\xd4\x01\n" +
"\x12RemoteAccessConfig\x12\x18\n" +
"\aenabled\x18\x01 \x01(\bR\aenabled\x12 \n" +
"\vserviceType\x18\x02 \x01(\tR\vserviceType\x12\x18\n" +
@@ -939,43 +1004,47 @@ func file_values_types_proto_rawDescGZIP() []byte {
return file_values_types_proto_rawDescData
}
-var file_values_types_proto_msgTypes = make([]protoimpl.MessageInfo, 12)
+var file_values_types_proto_msgTypes = make([]protoimpl.MessageInfo, 13)
var file_values_types_proto_goTypes = []any{
(*ManagementConfig)(nil), // 0:
dubbo.operator.v1alpha1.ManagementConfig
- (*ProxyConfig)(nil), // 1:
dubbo.operator.v1alpha1.ProxyConfig
- (*ProxylessConfig)(nil), // 2:
dubbo.operator.v1alpha1.ProxylessConfig
- (*MeshCNIConfig)(nil), // 3:
dubbo.operator.v1alpha1.MeshCNIConfig
- (*GatewayConfig)(nil), // 4:
dubbo.operator.v1alpha1.GatewayConfig
- (*GlobalConfig)(nil), // 5:
dubbo.operator.v1alpha1.GlobalConfig
- (*RemoteAccessConfig)(nil), // 6:
dubbo.operator.v1alpha1.RemoteAccessConfig
- (*EastWestGatewayEndpoint)(nil), // 7:
dubbo.operator.v1alpha1.EastWestGatewayEndpoint
- (*EastWestGatewayConfig)(nil), // 8:
dubbo.operator.v1alpha1.EastWestGatewayConfig
- (*MulticlusterConfig)(nil), // 9:
dubbo.operator.v1alpha1.MulticlusterConfig
- (*Values)(nil), // 10: dubbo.operator.v1alpha1.Values
- (*IntOrString)(nil), // 11:
dubbo.operator.v1alpha1.IntOrString
- (*wrapperspb.Int32Value)(nil), // 12: google.protobuf.Int32Value
- (*wrapperspb.StringValue)(nil), // 13: google.protobuf.StringValue
+ (*ActivationConfig)(nil), // 1:
dubbo.operator.v1alpha1.ActivationConfig
+ (*ProxyConfig)(nil), // 2:
dubbo.operator.v1alpha1.ProxyConfig
+ (*ProxylessConfig)(nil), // 3:
dubbo.operator.v1alpha1.ProxylessConfig
+ (*MeshCNIConfig)(nil), // 4:
dubbo.operator.v1alpha1.MeshCNIConfig
+ (*GatewayConfig)(nil), // 5:
dubbo.operator.v1alpha1.GatewayConfig
+ (*GlobalConfig)(nil), // 6:
dubbo.operator.v1alpha1.GlobalConfig
+ (*RemoteAccessConfig)(nil), // 7:
dubbo.operator.v1alpha1.RemoteAccessConfig
+ (*EastWestGatewayEndpoint)(nil), // 8:
dubbo.operator.v1alpha1.EastWestGatewayEndpoint
+ (*EastWestGatewayConfig)(nil), // 9:
dubbo.operator.v1alpha1.EastWestGatewayConfig
+ (*MulticlusterConfig)(nil), // 10:
dubbo.operator.v1alpha1.MulticlusterConfig
+ (*Values)(nil), // 11: dubbo.operator.v1alpha1.Values
+ (*IntOrString)(nil), // 12:
dubbo.operator.v1alpha1.IntOrString
+ (*wrapperspb.Int64Value)(nil), // 13: google.protobuf.Int64Value
+ (*wrapperspb.Int32Value)(nil), // 14: google.protobuf.Int32Value
+ (*wrapperspb.StringValue)(nil), // 15: google.protobuf.StringValue
}
var file_values_types_proto_depIdxs = []int32{
- 3, // 0: dubbo.operator.v1alpha1.ProxylessConfig.cni:type_name ->
dubbo.operator.v1alpha1.MeshCNIConfig
- 12, // 1: dubbo.operator.v1alpha1.GatewayConfig.replicaCount:type_name
-> google.protobuf.Int32Value
- 1, // 2: dubbo.operator.v1alpha1.GlobalConfig.proxy:type_name ->
dubbo.operator.v1alpha1.ProxyConfig
- 0, // 3: dubbo.operator.v1alpha1.GlobalConfig.management:type_name ->
dubbo.operator.v1alpha1.ManagementConfig
- 9, // 4: dubbo.operator.v1alpha1.GlobalConfig.multicluster:type_name
-> dubbo.operator.v1alpha1.MulticlusterConfig
- 2, // 5: dubbo.operator.v1alpha1.GlobalConfig.proxyless:type_name ->
dubbo.operator.v1alpha1.ProxylessConfig
- 4, // 6: dubbo.operator.v1alpha1.GlobalConfig.gateway:type_name ->
dubbo.operator.v1alpha1.GatewayConfig
- 7, // 7:
dubbo.operator.v1alpha1.EastWestGatewayConfig.gateways:type_name ->
dubbo.operator.v1alpha1.EastWestGatewayEndpoint
- 6, // 8:
dubbo.operator.v1alpha1.MulticlusterConfig.remoteAccess:type_name ->
dubbo.operator.v1alpha1.RemoteAccessConfig
- 8, // 9:
dubbo.operator.v1alpha1.MulticlusterConfig.eastWestGateway:type_name ->
dubbo.operator.v1alpha1.EastWestGatewayConfig
- 5, // 10: dubbo.operator.v1alpha1.Values.global:type_name ->
dubbo.operator.v1alpha1.GlobalConfig
- 12, // 11: dubbo.operator.v1alpha1.Values.replicaCount:type_name ->
google.protobuf.Int32Value
- 12, // 12: dubbo.operator.v1alpha1.IntOrString.intVal:type_name ->
google.protobuf.Int32Value
- 13, // 13: dubbo.operator.v1alpha1.IntOrString.strVal:type_name ->
google.protobuf.StringValue
- 14, // [14:14] is the sub-list for method output_type
- 14, // [14:14] is the sub-list for method input_type
- 14, // [14:14] is the sub-list for extension type_name
- 14, // [14:14] is the sub-list for extension extendee
- 0, // [0:14] is the sub-list for field type_name
+ 13, // 0: dubbo.operator.v1alpha1.ActivationConfig.port:type_name ->
google.protobuf.Int64Value
+ 4, // 1: dubbo.operator.v1alpha1.ProxylessConfig.cni:type_name ->
dubbo.operator.v1alpha1.MeshCNIConfig
+ 14, // 2: dubbo.operator.v1alpha1.GatewayConfig.replicaCount:type_name
-> google.protobuf.Int32Value
+ 2, // 3: dubbo.operator.v1alpha1.GlobalConfig.proxy:type_name ->
dubbo.operator.v1alpha1.ProxyConfig
+ 0, // 4: dubbo.operator.v1alpha1.GlobalConfig.management:type_name ->
dubbo.operator.v1alpha1.ManagementConfig
+ 10, // 5: dubbo.operator.v1alpha1.GlobalConfig.multicluster:type_name
-> dubbo.operator.v1alpha1.MulticlusterConfig
+ 3, // 6: dubbo.operator.v1alpha1.GlobalConfig.proxyless:type_name ->
dubbo.operator.v1alpha1.ProxylessConfig
+ 5, // 7: dubbo.operator.v1alpha1.GlobalConfig.gateway:type_name ->
dubbo.operator.v1alpha1.GatewayConfig
+ 1, // 8: dubbo.operator.v1alpha1.GlobalConfig.activation:type_name ->
dubbo.operator.v1alpha1.ActivationConfig
+ 8, // 9:
dubbo.operator.v1alpha1.EastWestGatewayConfig.gateways:type_name ->
dubbo.operator.v1alpha1.EastWestGatewayEndpoint
+ 7, // 10:
dubbo.operator.v1alpha1.MulticlusterConfig.remoteAccess:type_name ->
dubbo.operator.v1alpha1.RemoteAccessConfig
+ 9, // 11:
dubbo.operator.v1alpha1.MulticlusterConfig.eastWestGateway:type_name ->
dubbo.operator.v1alpha1.EastWestGatewayConfig
+ 6, // 12: dubbo.operator.v1alpha1.Values.global:type_name ->
dubbo.operator.v1alpha1.GlobalConfig
+ 14, // 13: dubbo.operator.v1alpha1.Values.replicaCount:type_name ->
google.protobuf.Int32Value
+ 14, // 14: dubbo.operator.v1alpha1.IntOrString.intVal:type_name ->
google.protobuf.Int32Value
+ 15, // 15: dubbo.operator.v1alpha1.IntOrString.strVal:type_name ->
google.protobuf.StringValue
+ 16, // [16:16] is the sub-list for method output_type
+ 16, // [16:16] is the sub-list for method input_type
+ 16, // [16:16] is the sub-list for extension type_name
+ 16, // [16:16] is the sub-list for extension extendee
+ 0, // [0:16] is the sub-list for field type_name
}
func init() { file_values_types_proto_init() }
@@ -989,7 +1058,7 @@ func file_values_types_proto_init() {
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor:
unsafe.Slice(unsafe.StringData(file_values_types_proto_rawDesc),
len(file_values_types_proto_rawDesc)),
NumEnums: 0,
- NumMessages: 12,
+ NumMessages: 13,
NumExtensions: 0,
NumServices: 0,
},
diff --git a/operator/pkg/render/manifest_test.go
b/operator/pkg/render/manifest_test.go
index 3a42bf5a..81d5451d 100644
--- a/operator/pkg/render/manifest_test.go
+++ b/operator/pkg/render/manifest_test.go
@@ -159,6 +159,94 @@ func
TestTelemetryValidationWebhookDoesNotRequireRevisionLabel(t *testing.T) {
t.Fatal("Telemetry validation webhook not rendered")
}
+// The activation scaler is only reachable if the flag, the container port and
+// the Service KEDA dials all agree on one number. Each is written in a
+// different template, so a mismatch renders cleanly and fails only at runtime.
+func TestGenerateManifestWiresActivationScalerEndToEnd(t *testing.T) {
+ manifests, _, err := GenerateManifest(nil,
[]string{"values.global.activation.port=26031"}, nil, nil)
+ if err != nil {
+ t.Fatalf("GenerateManifest() error = %v", err)
+ }
+
+ deployment := findManifest(t, manifests, "Deployment", "dubbod")
+ containers, ok, err := unstructured.NestedSlice(deployment.Object,
"spec", "template", "spec", "containers")
+ if err != nil || !ok || len(containers) == 0 {
+ t.Fatalf("deployment containers missing: ok=%v err=%v", ok, err)
+ }
+ execute := containers[0].(map[string]interface{})
+
+ args, _, _ := unstructured.NestedStringSlice(execute, "args")
+ if !hasArgValue(args, "--activationAddr", ":26031") {
+ t.Fatalf("args = %v, want --activationAddr :26031", args)
+ }
+
+ ports, ok, err := unstructured.NestedSlice(execute, "ports")
+ if err != nil || !ok {
+ t.Fatalf("container ports missing: ok=%v err=%v", ok, err)
+ }
+ if !hasContainerPort(ports, "activation", 26031) {
+ t.Fatal("dubbod deployment missing activation containerPort
26031")
+ }
+
+ service := findManifest(t, manifests, "Service", "dubbod-activation")
+ port, _, _ := unstructured.NestedInt64(findPort(t, service,
"grpc-activation"), "port")
+ if port != 26031 {
+ t.Fatalf("dubbod-activation port = %d, want 26031", port)
+ }
+
+ // Gateways broadcast their demand to every replica, so they need a name
+ // that resolves to all of them. A load-balanced Service would deliver
each
+ // report to one arbitrary replica, and KEDA's stream is on another.
+ replicas := findManifest(t, manifests, "Service",
"dubbod-activation-replicas")
+ clusterIP, _, _ := unstructured.NestedString(replicas.Object, "spec",
"clusterIP")
+ if clusterIP != "None" {
+ t.Fatalf("dubbod-activation-replicas clusterIP = %q, want
None", clusterIP)
+ }
+ notReady, _, _ := unstructured.NestedBool(replicas.Object, "spec",
"publishNotReadyAddresses")
+ if !notReady {
+ t.Fatal("dubbod-activation-replicas must publish not-ready
addresses so a held request is not blocked on readiness")
+ }
+}
+
+// Port zero is the documented off switch. It has to remove the listener, the
+// port and the Service together; leaving a Service behind would publish an
+// endpoint that refuses every connection.
+func TestGenerateManifestDisablesActivationAtPortZero(t *testing.T) {
+ manifests, _, err := GenerateManifest(nil,
[]string{"values.global.activation.port=0"}, nil, nil)
+ if err != nil {
+ t.Fatalf("GenerateManifest() error = %v", err)
+ }
+ if hasManifest(manifests, "Service", "dubbod-activation") {
+ t.Fatal("dubbod-activation Service rendered while activation is
disabled")
+ }
+ if hasManifest(manifests, "Service", "dubbod-activation-replicas") {
+ t.Fatal("dubbod-activation-replicas Service rendered while
activation is disabled")
+ }
+
+ deployment := findManifest(t, manifests, "Deployment", "dubbod")
+ containers, _, _ := unstructured.NestedSlice(deployment.Object, "spec",
"template", "spec", "containers")
+ execute := containers[0].(map[string]interface{})
+ args, _, _ := unstructured.NestedStringSlice(execute, "args")
+ if !hasArgValue(args, "--activationAddr", "") {
+ t.Fatalf("args = %v, want --activationAddr with an empty
value", args)
+ }
+ ports, _, _ := unstructured.NestedSlice(execute, "ports")
+ for _, raw := range ports {
+ if name, _, _ :=
unstructured.NestedString(raw.(map[string]interface{}), "name"); name ==
"activation" {
+ t.Fatal("activation containerPort rendered while
activation is disabled")
+ }
+ }
+}
+
+func hasArgValue(args []string, flag, value string) bool {
+ for i := 0; i < len(args)-1; i++ {
+ if args[i] == flag && args[i+1] == value {
+ return true
+ }
+ }
+ return false
+}
+
func TestGenerateManifestExposesDubbodPrometheusScrapeEndpoint(t *testing.T) {
manifests, _, err := GenerateManifest(nil, nil, nil, nil)
if err != nil {
diff --git a/tools/make/common.mk b/tools/make/common.mk
index 819df27e..92c51373 100644
--- a/tools/make/common.mk
+++ b/tools/make/common.mk
@@ -25,6 +25,16 @@ BIN_DIR ?= bin
GOTESTFLAGS ?= -race
GOLANGCI_LINT_VERSION ?= v2.6.2
+# Code generation. protoc-gen-go must match the google.golang.org/protobuf in
+# go.mod, and protoc must match the version already recorded in the generated
+# headers: a mismatch rewrites every file and turns `make check-generate` into
+# a permanent failure.
+PROTOC_VERSION ?= v33.0
+PROTOC_GEN_GO_VERSION ?= v1.36.11
+GOIMPORTS_VERSION ?= latest
+TOOL_BIN ?= $(CURDIR)/$(BIN_DIR)/tools
+OPERATOR_APIS_DIR := operator/pkg/apis
+
HUB ?= kdubbo
IMAGE_TAG ?= debug
IMAGE ?= $(HUB)/dubbod:$(IMAGE_TAG)
diff --git a/tools/make/lint.mk b/tools/make/lint.mk
index 6dae36a2..b689d4cf 100644
--- a/tools/make/lint.mk
+++ b/tools/make/lint.mk
@@ -71,3 +71,33 @@ check-clean-repo: ## Fail if the working tree is dirty.
.PHONY: check-tidy
check-tidy: tidy check-clean-repo ## go mod tidy, then fail if it changed
anything.
+
+.PHONY: generate
+generate: generate-proto generate-schema ## Re-run every code generator.
+
+.PHONY: generate-proto
+generate-proto: ## Regenerate the operator values API from its .proto.
+ @command -v protoc >/dev/null 2>&1 || { \
+ echo "protoc not found; install $(PROTOC_VERSION) from
https://github.com/protocolbuffers/protobuf/releases"; \
+ exit 1; \
+ }
+ @GOBIN=$(TOOL_BIN) go install
google.golang.org/protobuf/cmd/protoc-gen-go@$(PROTOC_GEN_GO_VERSION)
+ @cd $(OPERATOR_APIS_DIR) && PATH="$(TOOL_BIN):$$PATH" protoc \
+ --proto_path=proto \
+ --proto_path=$$(go list -f '{{ .Dir }}' -m k8s.io/api) \
+ --proto_path=$$(go list -f '{{ .Dir }}' -m k8s.io/apimachinery)
\
+ --go_out=. proto/values_types.proto
+ @# go_package is a full import path and protoc has no
paths=source_relative
+ @# here, so the output lands under a mirrored directory tree.
+ mv
$(OPERATOR_APIS_DIR)/dubbo.apache.org/dubbo/operator/pkg/apis/values_types.pb.go
\
+ $(OPERATOR_APIS_DIR)/values_types.pb.go
+ rm -rf $(OPERATOR_APIS_DIR)/dubbo.apache.org
+
+.PHONY: generate-schema
+generate-schema: ## Regenerate the resource schema from metadata.yaml.
+ @GOBIN=$(TOOL_BIN) go install
golang.org/x/tools/cmd/goimports@$(GOIMPORTS_VERSION)
+ @PATH="$(TOOL_BIN):$$PATH" go run
./pkg/config/schema/codegen/tools/collections.main.go
+
+.PHONY: check-generate
+check-generate: generate check-clean-repo ## Regenerate, then fail if anything
changed.
+ @echo "Generated sources are up to date."