diff --git a/api/v1alpha1/rule_types.go b/api/v1alpha1/rule_types.go
index 7b62eb2..87463f7 100644
--- a/api/v1alpha1/rule_types.go
+++ b/api/v1alpha1/rule_types.go
@@ -116,6 +116,19 @@ type NetworkRuleSpec struct {
// +kubebuilder:validation:XValidation:rule="(has(self.matchLabels) && size(self.matchLabels) > 0) || (has(self.matchExpressions) && size(self.matchExpressions) > 0)",message="backendSelector must not be empty"
BackendSelector metav1.LabelSelector `json:"backendSelector"`
+ // BackendAddresses, when set, limits the selected VPCAttachment backends
+ // to these IPv6 addresses. An explicitly empty list selects no backends;
+ // omitting the field preserves selector-only behavior. The gateway and
+ // backend nodes apply the same filter, so removing an address removes both
+ // its ingress selection and its ServiceVIPBinding. The addresses must still
+ // belong to selected attachments in VPCRef; this field cannot authorize a
+ // backend in another VPC.
+ // +optional
+ // +kubebuilder:validation:MaxItems=256
+ // +kubebuilder:validation:XValidation:rule="self.all(a, isIP(a) && a.contains(':'))",message="backendAddresses must contain IPv6 addresses"
+ // +listType=set
+ BackendAddresses *[]string `json:"backendAddresses,omitempty"`
+
// BackendPort is the destination port on every selected backend.
// +kubebuilder:validation:Required
// +kubebuilder:validation:Minimum=1
diff --git a/api/v1alpha1/rule_types_test.go b/api/v1alpha1/rule_types_test.go
index f0fca19..82fca1b 100644
--- a/api/v1alpha1/rule_types_test.go
+++ b/api/v1alpha1/rule_types_test.go
@@ -31,10 +31,13 @@ func newTestRule() *NetworkRule {
// copy: mutations to slices in the copy must not affect the original.
func TestNetworkRuleDeepCopy(t *testing.T) {
orig := newTestRule()
+ addresses := []string{"fd00:10::1"}
+ orig.Spec.BackendAddresses = &addresses
dup := orig.DeepCopy()
dup.Spec.VIPAddresses[0] = "2001:db8:1::20"
dup.Spec.BackendSelector.MatchLabels["app"] = "api"
+ (*dup.Spec.BackendAddresses)[0] = "fd00:10::2"
if orig.Spec.VIPAddresses[0] != "2001:db8:1::10" {
t.Errorf("VIPAddresses[0] mutated: got %q", orig.Spec.VIPAddresses[0])
@@ -42,6 +45,9 @@ func TestNetworkRuleDeepCopy(t *testing.T) {
if orig.Spec.BackendSelector.MatchLabels["app"] != "web" {
t.Errorf("BackendSelector.MatchLabels mutated: got %v", orig.Spec.BackendSelector.MatchLabels)
}
+ if (*orig.Spec.BackendAddresses)[0] != "fd00:10::1" {
+ t.Errorf("BackendAddresses mutated: got %v", *orig.Spec.BackendAddresses)
+ }
}
// TestNetworkRuleDeepCopyNil verifies DeepCopy on a nil pointer returns nil.
@@ -145,6 +151,24 @@ func TestNetworkRuleBackendFieldNames(t *testing.T) {
if _, ok := m["backends"]; ok {
t.Errorf("unexpected \"backends\" key: %v", m)
}
+ if _, ok := m["backendAddresses"]; ok {
+ t.Errorf("omitted backendAddresses must preserve selector-only behavior: %v", m)
+ }
+
+ empty := []string{}
+ rule := newTestRule()
+ rule.Spec.BackendAddresses = &empty
+ data, err = json.Marshal(rule.Spec)
+ if err != nil {
+ t.Fatalf("Marshal explicit empty backendAddresses: %v", err)
+ }
+ if err := json.Unmarshal(data, &m); err != nil {
+ t.Fatalf("Unmarshal explicit empty backendAddresses: %v", err)
+ }
+ addresses, ok := m["backendAddresses"].([]any)
+ if !ok || len(addresses) != 0 {
+ t.Errorf("explicit empty backendAddresses = %v, want []", m["backendAddresses"])
+ }
}
// TestNetworkRuleProtocolValues is a regression test pinning the accepted
diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go
index 1ca08dc..dc28e3d 100644
--- a/api/v1alpha1/zz_generated.deepcopy.go
+++ b/api/v1alpha1/zz_generated.deepcopy.go
@@ -1699,6 +1699,15 @@ func (in *NetworkRuleSpec) DeepCopyInto(out *NetworkRuleSpec) {
copy(*out, *in)
}
in.BackendSelector.DeepCopyInto(&out.BackendSelector)
+ if in.BackendAddresses != nil {
+ in, out := &in.BackendAddresses, &out.BackendAddresses
+ *out = new([]string)
+ if **in != nil {
+ in, out := *in, *out
+ *out = make([]string, len(*in))
+ copy(*out, *in)
+ }
+ }
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NetworkRuleSpec.
diff --git a/config/crd/network.datumapis.com_networkrules.yaml b/config/crd/network.datumapis.com_networkrules.yaml
index 2b5cf86..a681e8e 100644
--- a/config/crd/network.datumapis.com_networkrules.yaml
+++ b/config/crd/network.datumapis.com_networkrules.yaml
@@ -78,6 +78,23 @@ spec:
NetworkRuleSpec defines the desired ingress load-balancing state for a
tenant VPC.
properties:
+ backendAddresses:
+ description: |-
+ BackendAddresses, when set, limits the selected VPCAttachment backends
+ to these IPv6 addresses. An explicitly empty list selects no backends;
+ omitting the field preserves selector-only behavior. The gateway and
+ backend nodes apply the same filter, so removing an address removes both
+ its ingress selection and its ServiceVIPBinding. The addresses must still
+ belong to selected attachments in VPCRef; this field cannot authorize a
+ backend in another VPC.
+ items:
+ type: string
+ maxItems: 256
+ type: array
+ x-kubernetes-list-type: set
+ x-kubernetes-validations:
+ - message: backendAddresses must contain IPv6 addresses
+ rule: self.all(a, isIP(a) && a.contains(':'))
backendPort:
description: BackendPort is the destination port on every selected
backend.
diff --git a/docs/api/gateway.md b/docs/api/gateway.md
index 7e2818b..7ec2426 100644
--- a/docs/api/gateway.md
+++ b/docs/api/gateway.md
@@ -436,6 +436,7 @@ _Appears in:_
| `protocol` _[NetworkRuleProtocol](#networkruleprotocol)_ | Protocol is the transport protocol matched by VIPAddresses/Port. | | Enum: [tcp udp]
Required: \{\}
|
| `port` _integer_ | Port is the ingress port on VIPAddresses that this rule load-balances. | | Maximum: 65535
Minimum: 1
Required: \{\}
|
| `backendSelector` _[LabelSelector](https://kubernetes.io/docs/reference/generated/kubernetes-api/v/#labelselector-v1-meta)_ | BackendSelector selects the VPCAttachments (cloud.datumapis.com/v1alpha1,
in any namespace) that serve this rule, matched against each
VPCAttachment object's own labels. Only attachments whose status.vpc
equals VPCRef are candidates, whatever their labels, so a selector cannot
reach into another tenant's VPC. Each selected attachment contributes the
IPv6 addresses in its spec.interface.addresses as backends, on
BackendPort; the DSR datapath carries IPv6 backends only. An attachment
with no status.node or no IPv6 address is not a backend until it has
both. An empty selector is rejected rather than read as "every attachment
in the VPC". | | Required: \{\}
|
+| `backendAddresses` _string_ | BackendAddresses, when set, limits the selected VPCAttachment backends
to these IPv6 addresses. An explicitly empty list selects no backends;
omitting the field preserves selector-only behavior. The gateway and
backend nodes apply the same filter, so removing an address removes both
its ingress selection and its ServiceVIPBinding. The addresses must still
belong to selected attachments in VPCRef; this field cannot authorize a
backend in another VPC. | | MaxItems: 256
|
| `backendPort` _integer_ | BackendPort is the destination port on every selected backend. | | Maximum: 65535
Minimum: 1
Required: \{\}
|