diff --git a/api/v1alpha1/rule_admission_envtest_test.go b/api/v1alpha1/rule_admission_envtest_test.go new file mode 100644 index 0000000..8cba055 --- /dev/null +++ b/api/v1alpha1/rule_admission_envtest_test.go @@ -0,0 +1,100 @@ +// Copyright 2026 Datum Cloud, Inc. +// SPDX-License-Identifier: AGPL-3.0-or-later + +package v1alpha1_test + +import ( + "context" + "os" + "path/filepath" + "testing" + + corev1 "k8s.io/api/core/v1" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/envtest" + "sigs.k8s.io/yaml" +) + +func TestNetworkRuleBackendSelectionAdmission(t *testing.T) { + if os.Getenv("KUBEBUILDER_ASSETS") == "" { + t.Skip("set KUBEBUILDER_ASSETS for isolated API admission tests") + } + apiRoot := os.Getenv("GALACTIC_TEST_NETWORK_API") + if apiRoot == "" { + apiRoot = "../.." + } + data, err := os.ReadFile(filepath.Join(apiRoot, "config", "crd", "network.datumapis.com_networkrules.yaml")) + if err != nil { + t.Fatal(err) + } + crd := &apiextensionsv1.CustomResourceDefinition{} + if err := yaml.Unmarshal(data, crd); err != nil { + t.Fatal(err) + } + environment := &envtest.Environment{CRDs: []*apiextensionsv1.CustomResourceDefinition{crd}} + cfg, err := environment.Start() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := environment.Stop(); err != nil { + t.Errorf("stop API: %v", err) + } + }) + scheme := runtime.NewScheme() + if err := corev1.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + c, err := client.New(cfg, client.Options{Scheme: scheme}) + if err != nil { + t.Fatal(err) + } + ctx := context.Background() + if err := c.Create(ctx, &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "rule-admission"}}); err != nil { + t.Fatal(err) + } + + for _, tt := range []struct { + name string + selector map[string]any + addresses any + wantError bool + }{ + {name: "neither-selection", wantError: true}, + {name: "empty-selector", selector: map[string]any{}, wantError: true}, + {name: "selector-only", selector: map[string]any{"matchLabels": map[string]any{"app": "web"}}}, + {name: "addresses-only", addresses: []any{"fd00:10::1"}}, + {name: "explicit-empty-addresses", addresses: []any{}}, + {name: "empty-selector-with-addresses", selector: map[string]any{}, addresses: []any{"fd00:10::1"}}, + } { + t.Run(tt.name, func(t *testing.T) { + spec := map[string]any{ + "vpcRef": "vpc-a", "vipAddresses": []any{"2001:db8::10"}, + "protocol": "tcp", "port": int64(443), "backendPort": int64(8443), + } + if tt.selector != nil { + spec["backendSelector"] = tt.selector + } + if tt.addresses != nil { + spec["backendAddresses"] = tt.addresses + } + rule := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "network.datumapis.com/v1alpha1", "kind": "NetworkRule", + "metadata": map[string]any{"name": tt.name, "namespace": "rule-admission"}, + "spec": spec, + }} + err := c.Create(ctx, rule) + if (err != nil) != tt.wantError { + t.Fatalf("create error=%v, wantError=%t", err, tt.wantError) + } + if tt.wantError && !apierrors.IsInvalid(err) { + t.Fatalf("expected schema rejection, got %v", err) + } + }) + } +} diff --git a/api/v1alpha1/rule_types.go b/api/v1alpha1/rule_types.go index 87463f7..f9d656b 100644 --- a/api/v1alpha1/rule_types.go +++ b/api/v1alpha1/rule_types.go @@ -49,11 +49,10 @@ const ( // backend selection never needs a single "owning" node the way Full-NAT's // SNAT-source model did. // -// Backends are not listed. BackendSelector picks the VPCAttachments that -// serve the rule, so the backend set follows attachments as they are -// created, deleted or moved to another node, and galactic-router on each -// node generates the ServiceVIPBinding its backends need from the same -// selection. +// BackendSelector can pick VPCAttachments by label, while BackendAddresses +// can pin an explicit subset of their IPv6 addresses. Either field may select +// backends, so galactic-router on each node generates ServiceVIPBindings from +// the same VPC-scoped attachment set as the gateways. // // +kubebuilder:object:root=true // +kubebuilder:subresource:status @@ -72,6 +71,7 @@ type NetworkRule struct { // NetworkRuleSpec defines the desired ingress load-balancing state for a // tenant VPC. +// +kubebuilder:validation:XValidation:rule="has(self.backendAddresses) || (has(self.backendSelector) && ((has(self.backendSelector.matchLabels) && size(self.backendSelector.matchLabels) > 0) || (has(self.backendSelector.matchExpressions) && size(self.backendSelector.matchExpressions) > 0)))",message="backendSelector must be nonempty unless backendAddresses is set" type NetworkRuleSpec struct { // VPCRef is the opaque identifier of the target VPC this rule applies // to. This repo does not own the VPC API and does not validate the @@ -110,19 +110,17 @@ type NetworkRuleSpec struct { // 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". - // +kubebuilder:validation:Required - // +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. + // both. When omitted, BackendAddresses must be set; only attachments whose + // authoritative status.vpc equals VPCRef are considered for those addresses. + // +optional + BackendSelector metav1.LabelSelector `json:"backendSelector,omitempty"` + + // BackendAddresses, when set, limits the VPC's VPCAttachment backends to + // these IPv6 addresses, also applying BackendSelector if it is present. 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. An address in another VPC is never selected. // +optional // +kubebuilder:validation:MaxItems=256 // +kubebuilder:validation:XValidation:rule="self.all(a, isIP(a) && a.contains(':'))",message="backendAddresses must contain IPv6 addresses" diff --git a/config/crd/network.datumapis.com_networkrules.yaml b/config/crd/network.datumapis.com_networkrules.yaml index a681e8e..7b68c6e 100644 --- a/config/crd/network.datumapis.com_networkrules.yaml +++ b/config/crd/network.datumapis.com_networkrules.yaml @@ -50,11 +50,10 @@ spec: backend selection never needs a single "owning" node the way Full-NAT's SNAT-source model did. - Backends are not listed. BackendSelector picks the VPCAttachments that - serve the rule, so the backend set follows attachments as they are - created, deleted or moved to another node, and galactic-router on each - node generates the ServiceVIPBinding its backends need from the same - selection. + BackendSelector can pick VPCAttachments by label, while BackendAddresses + can pin an explicit subset of their IPv6 addresses. Either field may select + backends, so galactic-router on each node generates ServiceVIPBindings from + the same VPC-scoped attachment set as the gateways. properties: apiVersion: description: |- @@ -80,13 +79,12 @@ spec: 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. + BackendAddresses, when set, limits the VPC's VPCAttachment backends to + these IPv6 addresses, also applying BackendSelector if it is present. 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. An address in another VPC is never selected. items: type: string maxItems: 256 @@ -112,8 +110,8 @@ spec: 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". + both. When omitted, BackendAddresses must be set; only attachments whose + authoritative status.vpc equals VPCRef are considered for those addresses. properties: matchExpressions: description: matchExpressions is a list of label selector requirements. @@ -158,10 +156,6 @@ spec: type: object type: object x-kubernetes-map-type: atomic - x-kubernetes-validations: - - message: backendSelector must not be empty - rule: (has(self.matchLabels) && size(self.matchLabels) > 0) || (has(self.matchExpressions) - && size(self.matchExpressions) > 0) port: description: Port is the ingress port on VIPAddresses that this rule load-balances. @@ -200,12 +194,17 @@ spec: type: string required: - backendPort - - backendSelector - port - protocol - vipAddresses - vpcRef type: object + x-kubernetes-validations: + - message: backendSelector must be nonempty unless backendAddresses is + set + rule: has(self.backendAddresses) || (has(self.backendSelector) && ((has(self.backendSelector.matchLabels) + && size(self.backendSelector.matchLabels) > 0) || (has(self.backendSelector.matchExpressions) + && size(self.backendSelector.matchExpressions) > 0))) status: description: NetworkRuleStatus defines the observed state of a NetworkRule. properties: diff --git a/docs/api/gateway.md b/docs/api/gateway.md index 7ec2426..3bc2c7f 100644 --- a/docs/api/gateway.md +++ b/docs/api/gateway.md @@ -374,11 +374,10 @@ flow (internal/maglev), and forwards without rewriting anything — backend selection never needs a single "owning" node the way Full-NAT's SNAT-source model did. -Backends are not listed. BackendSelector picks the VPCAttachments that -serve the rule, so the backend set follows attachments as they are -created, deleted or moved to another node, and galactic-router on each -node generates the ServiceVIPBinding its backends need from the same -selection. +BackendSelector can pick VPCAttachments by label, while BackendAddresses +can pin an explicit subset of their IPv6 addresses. Either field may select +backends, so galactic-router on each node generates ServiceVIPBindings from +the same VPC-scoped attachment set as the gateways. @@ -435,8 +434,8 @@ _Appears in:_ | `vipAddresses` _string array_ | VIPAddresses is the list of ingress VIP addresses (IPv4 and/or IPv6)
this rule provisions on the assigned gateway node(s). | | MaxItems: 8
MinItems: 1
Required: \{\}
items:MaxLength: 45
| | `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
| +| `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. When omitted, BackendAddresses must be set; only attachments whose
authoritative status.vpc equals VPCRef are considered for those addresses. | | | +| `backendAddresses` _string_ | BackendAddresses, when set, limits the VPC's VPCAttachment backends to
these IPv6 addresses, also applying BackendSelector if it is present. 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. An address in another VPC is never selected. | | MaxItems: 256
| | `backendPort` _integer_ | BackendPort is the destination port on every selected backend. | | Maximum: 65535
Minimum: 1
Required: \{\}
|